Skip to content

feat(levm): implement EIP-7906 TXTRACE and EVENTDATACOPY opcodes#6891

Open
edg-l wants to merge 2 commits into
eip-8141-1from
eip-7906
Open

feat(levm): implement EIP-7906 TXTRACE and EVENTDATACOPY opcodes#6891
edg-l wants to merge 2 commits into
eip-8141-1from
eip-7906

Conversation

@edg-l

@edg-l edg-l commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Stacks on EIP-8141 (#6326). Adds two LEVM opcodes from EIP-7906 (Transaction Assertions via State Diff), gated to Hegota.

TXTRACE (0xB5, gas 100): reads the current transaction's state diff at the point of the call: balance changes, storage changes, deployed contracts, events (address, topics, data length), gas pre-charge and gas payer. Takes (param, index) on the stack, like FRAMEPARAM.

EVENTDATACOPY (0xB6): copies an event's non-indexed data into memory, with the same gas and semantics as CALLDATACOPY.

State diff is built from initial_accounts_state vs current_accounts_state, sorted by address (uint160) then slot (uint256); events stay in emission order via substate.extract_logs(). The diff helpers and both handlers live in a new file crates/vm/levm/src/opcode_handlers/tx_trace.rs; the rest is opcode/table wiring in opcodes.rs, the gas constant in gas_cost.rs, and two helpers exposed as pub(crate) in frame_tx.rs.

Tests: 34 bytecode tests in test/tests/levm/eip7906_tests.rs covering counts, sort order, net-zero exclusion, deploys, events across reverted subcalls, EVENTDATACOPY bounds and halts, the gas-payer subtraction guarantee, the frame-tx payer path, blob-fee pre-charge, STATICCALL, nested calls, and fork gating.

Caveats

The EIP is Draft and has no conformance tests. It marks opcode bytes and gas costs as TBD. Chosen here: bytes 0xB5/0xB6 (next free after EIP-8141), TXTRACE gas 100 (the EIP's own example value), EVENTDATACOPY gas as CALLDATACOPY.

These choices were cross-checked against the author PoC (forshtat/ethrex, branch implement_7906). The gas value, the EVENTDATACOPY formula, the sort order, the event ordering, and the EVENTDATACOPY stack order all match. Three things differ:

  • EIP-7702 delegations: the EIP only says "newly deployed contracts" and does not mention delegation designators. This PR excludes delegated accounts from contracts_deployed; the PoC includes them. Flagged pending spec clarification, simple to flip.
  • TXTRACE operand order: index on top, matching ethrex's FRAMEPARAM and the EIP-8141 text ("frameIndex on top, param second"). The PoC puts param on top.
  • gas_pre_charge basis: uses the effective gas price and base blob fee, which is what ethrex deducts up front, so balance_before - balance_after == gas_pre_charge holds for the payer. The PoC uses max-fee values.

gas_payer returns the sender until APPROVE_PAYMENT runs; the spec only defines the final payer and expects assertions in the last frame.

Draft because it depends on the unmerged EIP-8141 PR (#6326) and the EIP is not final.

@github-actions github-actions Bot added the levm Lambda EVM implementation label Jun 18, 2026
@edg-l edg-l moved this to In Review in ethrex_l1 Jun 18, 2026
@edg-l edg-l marked this pull request as ready for review June 18, 2026 15:02
@edg-l edg-l requested a review from a team as a code owner June 18, 2026 15:02
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 272
Total lines removed: 0
Total lines changed: 272

Detailed view
+-------------------------------------------------------+-------+------+
| File                                                  | Lines | Diff |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/gas_cost.rs                 | 893   | +1   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/mod.rs      | 32    | +1   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/tx_trace.rs | 264   | +264 |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcodes.rs                  | 627   | +6   |
+-------------------------------------------------------+-------+------+

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

Overall, this is a solid implementation of EIP-7906 with comprehensive test coverage. The code correctly handles transaction introspection, frame transactions, and EIP-7702 delegation exclusion.

Critical Issues

None found — the implementation correctly handles edge cases (overflows, bounds checking, reverts) and follows Ethereum consensus rules.

Performance Concerns

crates/vm/levm/src/opcode_handlers/tx_trace.rs

  1. Repeated state diff computation (Lines 205–290)
    The OpTxTraceHandler::eval method recomputes balance_changes(), slot_changes(), and deployed_contracts() on every opcode invocation. If a contract calls TXTRACE multiple times, this becomes O(N×M) where N is the number of state changes and M is the call count.

    Suggestion: Cache the computed vectors in a OnceCell or similar within the VM's substate for the duration of the transaction, or document this as acceptable per the EIP's "warm access" gas cost assumption.

  2. Log cloning (Line 170)
    ordered_tx_logs(vm) clones all logs via extract_logs(). For transactions with many logs, this is expensive memory allocation on every EVENTDATACOPY or TXTRACE call.

    Suggestion: Consider returning references or using an iterator if the borrow checker permits, though the current immutable-then-mutable borrow pattern may require the clone.

Code Quality & Best Practices

crates/vm/levm/src/opcode_handlers/tx_trace.rs

  1. Error type consistency (Lines 215, 229, etc.)
    Using ExceptionalHalt::InvalidOpcode for runtime bounds errors (e.g., index out of range) is unconventional — InvalidOpcode typically means the byte is undefined. Consider if OutOfGas or a dedicated halt reason is more appropriate per the EIP specification.

  2. Unnecessary H256→U256 conversion (Lines 126–128)

    U256::from_big_endian(a.1.as_bytes()).cmp(&U256::from_big_endian(b.1.as_bytes()))

    H256 implements Ord using lexicographic (big-endian) byte comparison, which is identical to uint256 big-endian ordering. The conversion is redundant:

    changes.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
  3. Magic numbers in param matching (Lines 205–290)
    The hex literals (0x00, 0x03, 0x0F, etc.) are EIP-defined constants. Consider defining named constants for readability:

    const PARAM_BALANCE_COUNT: u8 = 0x00;
    const PARAM_BALANCE_ADDRESS: u8 = 0x03;
    // ...

Minor Issues

  1. Gas cost comment accuracy (gas_cost.rs Line 73)
    The comment states "100 matches the EIP's own example", but verify this aligns with the final EIP specification if it has been updated since the provisional draft.

  2. Test helper duplication (eip7906_tests.rs Lines 45–47)
    The address_word helper function is duplicated between the main test module and the frame_tx submodule (Line 1110). Consider importing from a common test utility or the main module.

Security Verification

  • EIP-7702 delegation exclusion (Line 141): Correctly checks code_has_delegation to exclude 0xef0100 || addr designators from deployed contract counts.
  • Bounds checking (Line 352): EVENTDATACOPY correctly validates data_offset + length with checked_add to prevent overflow.
  • STATICCALL safety: TXTRACE only reads state and pushes to stack; no storage writes. The implementation is safe for read-only contexts.

Summary

The PR is ready for merge with optional performance optimizations. The repeated computation in TXTRACE is the primary concern for DoS resistance if the gas cost (100) is significantly cheaper than the worst-case O(N) state traversal. Consider benchmarking with a transaction containing thousands of state changes.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. TXTRACE misses storage deletions caused by SELFDESTRUCT, so its slot_changes view is incomplete. In tx_trace.rs, slot_changes() only iterates current.storage. But on destruction, default_hook.rs replaces the account with LevmAccount::default() and marks it destroyed, which drops the old storage map entirely. Any pre-state slots cleared to zero by selfdestruct will therefore never appear in TXTRACE(0x01, 0x06..0x09). For an opcode that is meant to expose transaction-scoped storage changes, that is a correctness bug. I’d expect this to walk the union of initial.storage and current.storage, or to special-case destroyed accounts and emit (before, 0) for the cleared slots.

  2. The new opcodes are severely underpriced relative to the work they do, which creates a consensus-critical CPU amplification risk. TXTRACE charges a flat 100 gas, but the handler repeatedly rebuilds and sorts full vectors on each access: balance_changes(), slot_changes(), deployed_contracts(), and cloned logs via ordered_tx_logs(). The hot paths at tx_trace.rs:199, tx_trace.rs:211, tx_trace.rs:224, tx_trace.rs:241, and tx_trace.rs:339 make this easy to hit from a loop. A contract can turn one transaction with many touched accounts/slots/logs into superlinear host work for very little gas. This should be cached once per transaction/frame or repriced before merging.

Verification

I could not run cargo test -p test eip7906_tests here because rustup tried to write under /home/runner/.rustup, which is read-only in this environment. Fork gating itself looks correct in opcodes.rs.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Now I have a complete view of the diff. Let me synthesize the review.


PR #6891feat(levm): implement EIP-7906 TXTRACE and EVENTDATACOPY opcodes

Overview

This PR adds two new opcodes for EIP-7906 (Transaction Assertions via State Diff) in LEVM: TXTRACE (0xB5, reads the current tx's state diff) and EVENTDATACOPY (0xB6, copies emitted event data into memory). The implementation is stacked on the EIP-8141 branch and is clearly marked as draft, matching the EIP's own draft status. Test coverage is thorough at 34 bytecode-level tests.


Correctness

balance_changes only iterates current, not initial (tx_trace.rs:84-98)

The function iterates current_accounts_state and compares against initial. Accounts that existed in initial but are absent from current would be silently missed. In practice, any balance modification requires loading the account into current, so this is likely safe. Still worth a comment acknowledging the invariant: "any touched account is present in current", since the correctness depends on it.

Gas is charged before event/data bounds checks in EVENTDATACOPY (tx_trace.rs:377-403)

vm.current_call_frame.increase_consumed_gas(gas_cost::calldatacopy(...))?;
// ... then event_index and data_offset are validated

This matches standard EVM convention (CALLDATACOPY charges gas before using the data), but it means gas is consumed even on halting paths. This is intentional and correct; a comment clarifying "gas charged before bounds checks, per EVM convention" would help readers who might consider reordering.

Fork gating not visible in the diff (opcodes.rs:670-671)

// EIP-7906 transaction-trace opcodes (Hegota)
opcode_table[Opcode::TXTRACE as usize] = OpCodeFn::new::<OpTxTraceHandler>();
opcode_table[Opcode::EVENTDATACOPY as usize] = OpCodeFn::new::<OpEventDataCopyHandler>();

The comment notes "Hegota" but no if fork >= Fork::Hegota guard is visible in this hunk. Since the PR is stacked on EIP-8141 (which adds the surrounding Hegota block), the conditional is presumably in the already-merged context. The tests at eip7906_tests.rs:2021-2048 confirm these opcodes are invalid at Amsterdam, which would catch a missing guard. Verify the insertion point is inside the existing Hegota-conditional block rather than unconditionally added.

topic_at panics instead of halting if n >= 4 is somehow passed (tx_trace.rs:168-170)

pub(crate) fn topic_at(log: &Log, n: usize) -> Option<H256> {
    log.topics.get(n).copied()
}

topics is a Vec, so get returns None safely. Then at the call site (tx_trace.rs:310) the None is mapped to InvalidOpcode halt. This is fine — get is safe and the comment even calls it out. No issue.


Performance / Gas Safety

O(n) recomputation of the entire state diff on every TXTRACE call

Every TXTRACE opcode invocation re-runs balance_changes(), slot_changes(), or deployed_contracts(), each of which iterates and sorts all accounts in current_accounts_state. For a transaction with a large state diff (e.g., many SSTORE calls, which cost ~2200 gas each), each subsequent TXTRACE call (100 gas) re-sorts O(K) entries.

Concrete example: 100 distinct SSTORE writes costs ~220,000 gas to set up, but a loop calling TXTRACE 0x01 (slot count) 100 times costs only 10,000 gas while doing 100 × O(100 log 100) work. The gas cost of 100 is acknowledged as provisional, but the implementation should either cache the computed state diff per EVM step or document this as a known gap pending the EIP's final gas schedule.

slot_changes sort uses redundant U256 conversion for H256 keys (tx_trace.rs:124-127)

changes.sort_by(|a, b| {
    a.0.cmp(&b.0).then_with(|| {
        U256::from_big_endian(a.1.as_bytes()).cmp(&U256::from_big_endian(b.1.as_bytes()))
    })
});

H256 bytes are already big-endian, so a.1.as_bytes().cmp(b.1.as_bytes()) gives the same uint256 ordering without the allocation. Replace with a direct byte slice comparison:

a.1.as_bytes().cmp(b.1.as_bytes())

Naming / Readability

Local variable gas_cost shadows the module gas_cost in gas_pre_charge (tx_trace.rs:184-195)

use crate::gas_cost;
// ...
let gas_cost = U256::from(total_gas_limit)   // shadows the module
    .checked_mul(effective_gas_price)
    .ok_or(ExceptionalHalt::OutOfGas)?;
let blob_gas = U256::from(blob_count)
    .checked_mul(U256::from(gas_cost::BLOB_GAS_PER_BLOB))  // still resolves correctly

Rust's namespace separation (values vs. type/module paths) means this compiles correctly — gas_cost::BLOB_GAS_PER_BLOB still resolves the module. However it's a readability hazard. Rename the local to base_gas_cost or exec_gas_cost to avoid confusion.


Minor Points

  • ordered_tx_logs (tx_trace.rs:209-211) is a one-line wrapper that exists mainly for its doc comment warning about double-counting. The comment is valuable; consider either inlining the call with an inline comment or keeping the wrapper (current choice is fine).

  • #[allow(dead_code)] on balance_of in the test file (eip7906_tests.rs:742) is appropriate for a test helper. Fine.

  • The Harness::to and Harness::value methods are also #[allow(dead_code)]. If these are not used by any existing test, they could be removed to avoid dead code, or kept for future tests. Low priority.

  • The test balance_before_zero_for_newly_created_account scans up to count.min(3) indices and asserts found. This means if count > 3 the test could silently not find OTHER. Since there should be exactly 3 balance changes (sender, contract, OTHER), the min(3) is fine but the intent would be clearer as count directly with an assertion that count == 3.


Test Coverage Assessment

Excellent breadth: counts, sorting, net-zero exclusion, EIP-7702 delegation exclusion, event ordering across reverted subcalls, EVENTDATACOPY bounds/halts, all halt conditions, gas-payer subtraction guarantee, frame-tx payer path, blob-fee pre-charge, STATICCALL, nested-call visibility, and fork gating. The frame-tx module at the end mirrors the EIP-8141 test harness closely.

One gap: no test for TXTRACE with param values in the boundary 0x0F..=0x12 (topic0–topic3) when the log has exactly 1, 2, or 3 topics, verifying that topic_at(log, n) halts for absent topics mid-range rather than only at 0. topic_index_beyond_count_halts covers topic1 on LOG1 (1 topic), but not topic2 on LOG2 (2 topics) or topic3 on LOG3 (3 topics).


Summary

The implementation is solid and the test coverage is strong. The two items that warrant attention before merge are:

  1. Confirm the fork gating (Hegota conditional) encloses the TXTRACE/EVENTDATACOPY opcode table entries — the diff is ambiguous on this point.
  2. The O(n) per-call recomputation vs. 100-gas cost. Either add a TODO noting this needs revisiting when the EIP finalizes its gas schedule, or pre-compute and cache the state diff snapshot at TXTRACE entry.

The U256 roundtrip in the slot sort and the gas_cost variable shadowing are minor but easy fixes.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown

Benchmark Results Comparison

No significant difference was registered for any benchmark run.

Detailed Results

Benchmark Results: BubbleSort

Command Mean [s] Min [s] Max [s] Relative
main_revm_BubbleSort 3.051 ± 0.037 2.988 3.126 1.16 ± 0.02
main_levm_BubbleSort 2.640 ± 0.076 2.597 2.838 1.01 ± 0.03
pr_revm_BubbleSort 3.055 ± 0.053 2.997 3.123 1.16 ± 0.02
pr_levm_BubbleSort 2.623 ± 0.021 2.594 2.653 1.00

Benchmark Results: ERC20Approval

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Approval 995.6 ± 11.4 981.7 1021.9 1.01 ± 0.01
main_levm_ERC20Approval 986.9 ± 4.6 977.3 992.8 1.01 ± 0.01
pr_revm_ERC20Approval 981.7 ± 5.5 975.4 990.2 1.00
pr_levm_ERC20Approval 995.9 ± 13.2 978.5 1021.8 1.01 ± 0.01

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 134.0 ± 2.3 131.2 138.6 1.01 ± 0.02
main_levm_ERC20Mint 151.3 ± 0.6 150.1 151.9 1.14 ± 0.01
pr_revm_ERC20Mint 132.2 ± 0.8 131.5 134.1 1.00
pr_levm_ERC20Mint 151.8 ± 2.9 149.5 159.7 1.15 ± 0.02

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 231.6 ± 1.3 229.5 233.7 1.00
main_levm_ERC20Transfer 250.8 ± 2.2 248.0 255.1 1.08 ± 0.01
pr_revm_ERC20Transfer 231.9 ± 2.2 230.1 236.8 1.00 ± 0.01
pr_levm_ERC20Transfer 252.4 ± 2.0 249.4 256.0 1.09 ± 0.01

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 225.8 ± 4.9 223.2 239.7 1.00 ± 0.03
main_levm_Factorial 248.9 ± 4.6 245.1 258.6 1.10 ± 0.03
pr_revm_Factorial 225.8 ± 3.8 222.5 235.3 1.00
pr_levm_Factorial 248.5 ± 2.4 245.9 252.6 1.10 ± 0.02

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.629 ± 0.048 1.573 1.748 1.00 ± 0.06
main_levm_FactorialRecursive 9.271 ± 0.034 9.226 9.319 5.70 ± 0.32
pr_revm_FactorialRecursive 1.625 ± 0.091 1.466 1.747 1.00
pr_levm_FactorialRecursive 9.296 ± 0.032 9.249 9.351 5.72 ± 0.32

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 205.7 ± 3.8 203.8 216.5 1.00
main_levm_Fibonacci 228.6 ± 2.0 226.2 232.2 1.11 ± 0.02
pr_revm_Fibonacci 210.6 ± 9.8 203.1 224.9 1.02 ± 0.05
pr_levm_Fibonacci 230.0 ± 3.6 226.9 237.4 1.12 ± 0.03

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 856.5 ± 30.7 819.8 924.6 1.18 ± 0.04
main_levm_FibonacciRecursive 725.9 ± 4.4 720.6 731.5 1.00
pr_revm_FibonacciRecursive 847.6 ± 22.7 820.1 882.0 1.17 ± 0.03
pr_levm_FibonacciRecursive 731.8 ± 13.6 719.4 766.7 1.01 ± 0.02

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 8.5 ± 0.1 8.4 8.5 1.01 ± 0.01
main_levm_ManyHashes 9.3 ± 0.1 9.2 9.4 1.11 ± 0.01
pr_revm_ManyHashes 8.4 ± 0.0 8.3 8.4 1.00
pr_levm_ManyHashes 9.4 ± 0.1 9.3 9.7 1.12 ± 0.02

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 266.6 ± 3.7 263.8 276.6 1.35 ± 0.02
main_levm_MstoreBench 197.6 ± 1.0 195.6 198.9 1.00
pr_revm_MstoreBench 267.8 ± 4.4 263.7 277.9 1.36 ± 0.02
pr_levm_MstoreBench 199.5 ± 3.6 197.2 209.7 1.01 ± 0.02

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 291.8 ± 0.9 290.5 293.6 1.19 ± 0.01
main_levm_Push 266.5 ± 54.6 242.0 417.4 1.09 ± 0.22
pr_revm_Push 292.4 ± 1.4 290.6 295.5 1.20 ± 0.01
pr_levm_Push 244.3 ± 2.8 241.9 250.2 1.00

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 168.4 ± 6.7 162.3 184.2 1.72 ± 0.07
main_levm_SstoreBench_no_opt 98.2 ± 0.3 97.7 98.6 1.00
pr_revm_SstoreBench_no_opt 170.8 ± 5.0 162.6 180.5 1.74 ± 0.05
pr_levm_SstoreBench_no_opt 99.4 ± 2.3 98.0 104.5 1.01 ± 0.02

@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown

Greptile Summary

This PR implements two new EVM opcodes from draft EIP-7906 — TXTRACE (0xB5) and EVENTDATACOPY (0xB6) — gated to the Hegota fork, stacking on the EIP-8141 frame-transaction work. The opcodes let contracts introspect the transaction's running state diff (balance changes, storage-slot changes, deployed contracts, emitted events, gas pre-charge, and gas payer) at the point of the call.

  • tx_trace.rs adds the two opcode handlers plus four pure helper functions (balance_changes, slot_changes, deployed_contracts, gas_pre_charge) that diff initial_accounts_state against current_accounts_state and read logs from the substate chain; frame_tx.rs widens two helpers to pub(crate) so they can be shared.
  • Opcode byte assignments (0xB5/0xB6), the flat 100-gas cost for TXTRACE, and EVENTDATACOPY's CALLDATACOPY-mirrored gas are all provisional (EIP marks them TBD), with deviations from the author PoC documented in the PR description; 34 bytecode tests cover counts, sort order, net-zero exclusion, deploys, events across reverted subcalls, EVENTDATACOPY bounds, gas-payer paths, and fork gating.

Confidence Score: 4/5

Safe to merge as a draft stacked on EIP-8141; no state-corrupting or security-breaking paths were found.

The opcode handlers are correctly gated to Hegota, borrow-checker discipline is solid (owned log clones before mutable borrows, immutable DB refs dropped before stack push), and zero-value SSTORE entries are stored explicitly so slot_changes is accurate. The main concerns are quality/future-proofing: the three diff helpers are recomputed from scratch on every opcode invocation with no caching, and the deployed_contracts delegation check silently passes when the code hash is absent from the codes map. Neither causes wrong results today, but they are worth addressing before the EIP is finalised and gas costs are locked in.

crates/vm/levm/src/opcode_handlers/tx_trace.rs and test/tests/levm/eip7906_tests.rs deserve a second look.

Important Files Changed

Filename Overview
crates/vm/levm/src/opcode_handlers/tx_trace.rs New file implementing TXTRACE and EVENTDATACOPY handlers with helper functions for balance/slot/deploy diffs. Core logic is correct; has O(n) per-call recomputation issue and an implicit invariant in deployed_contracts when code is absent from the codes map.
crates/vm/levm/src/gas_cost.rs Adds TXTRACE gas constant (100) with a clear caveat that it is provisional per the draft EIP. No issues.
crates/vm/levm/src/opcode_handlers/frame_tx.rs Widens visibility of index_to_usize and compute_tx_cost to pub(crate) so the new tx_trace module can reuse them. Minimal, correct change.
crates/vm/levm/src/opcodes.rs Adds TXTRACE=0xB5 and EVENTDATACOPY=0xB6 to the enum and opcode table, gated to build_opcode_table_hegota. The fork-gate test is extended to cover 0xB5/0xB6 on pre-Hegota forks.
crates/vm/levm/src/opcode_handlers/mod.rs Adds pub mod tx_trace. Trivial wiring change.
test/tests/levm/eip7906_tests.rs 1839-line bytecode test suite covering 34 scenarios: counts, sort order, net-zero exclusion, deploys, events, EVENTDATACOPY, gas payer, fork gating. Missing a test for a pre-existing non-zero slot set to zero appearing in slot_changes.
test/tests/levm/mod.rs Registers the new eip7906_tests module. No issues.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
crates/vm/levm/src/opcode_handlers/tx_trace.rs:199-261
**O(n) recomputation on every indexed TXTRACE call**

`balance_changes`, `slot_changes`, and `deployed_contracts` are fully recomputed on every opcode invocation — there is no caching across calls. A contract that reads all balance-change addresses (params `0x03``0x05` × `n` iterations) incurs O(n) work per call, giving O(n²) total for a full scan, all charged at the fixed 100-gas rate. With a transaction that touches thousands of accounts or slots, this creates an unbounded CPU/time cost for a bounded gas spend. The EIP marks the gas cost TBD and this is draft, but the disparity should be tracked as a known concern before the cost is finalised.

### Issue 2 of 3
crates/vm/levm/src/opcode_handlers/tx_trace.rs:103-112
**Delegation check silently skipped when code is absent from `codes` map**

When `codes.get(&code_hash_after)` returns `None`, the `if let Some(code) = ... && code_has_delegation(...)` guard short-circuits, and the account is included in `deployed_contracts` without the EIP-7702 delegation check. This is safe only under the implicit invariant that every address that reaches this point (newly deployed, `was_empty` guard passed) always has its bytecode present in `codes`. If that invariant is violated — e.g., if a future code path introduces a CREATE that commits the account before storing the code — a delegation designator could silently appear in the deployed list. A `debug_assert!` or a comment explicitly stating the invariant would make the assumption auditable.

### Issue 3 of 3
test/tests/levm/eip7906_tests.rs:912-925
**Missing test: pre-existing non-zero slot SSTORE'd to zero should appear in `slot_changes`**

The suite tests restoring a slot to its original value (excluded) and writing new non-zero slots (included), but there is no test for an account that has a non-zero slot in prestate and explicitly sets it to `0`. The `slot_changes` implementation correctly handles this case (since `update_account_storage` always calls `account.storage.insert(key, new_value)` even for zero), but the gap means a future regression — such as pruning zero-value entries from storage — would go undetected by the EIP-7906 test suite.

Reviews (1): Last reviewed commit: "feat(levm): implement EIP-7906 TXTRACE a..." | Re-trigger Greptile

Comment on lines +199 to +261
let changes = balance_changes(initial, current);
let idx = index_to_usize(in2)?;
let (address, before, after) =
*changes.get(idx).ok_or(ExceptionalHalt::InvalidOpcode)?;
match param {
0x03 => address_to_u256(address),
0x04 => before,
_ => after,
}
}
// -- storage-slot changes (in2 = index) --
0x06..=0x09 => {
let changes = slot_changes(initial, current);
let idx = index_to_usize(in2)?;
let (address, slot, before, after) =
*changes.get(idx).ok_or(ExceptionalHalt::InvalidOpcode)?;
match param {
0x06 => address_to_u256(address),
0x07 => U256::from_big_endian(slot.as_bytes()),
0x08 => before,
_ => after,
}
}
// -- deployed contracts (in2 = index) --
0x0A | 0x0B => {
let deployed = deployed_contracts(&vm.db.codes, initial, current)?;
let idx = index_to_usize(in2)?;
let (address, code_hash) =
*deployed.get(idx).ok_or(ExceptionalHalt::InvalidOpcode)?;
if param == 0x0A {
address_to_u256(address)
} else {
U256::from_big_endian(code_hash.as_bytes())
}
}
// -- events count (in2 must be 0) --
0x0C => {
require_zero(in2)?;
U256::from(ordered_tx_logs(vm).len())
}
// -- event fields (in2 = event index) --
0x0D..=0x13 => {
let logs = ordered_tx_logs(vm);
let idx = index_to_usize(in2)?;
let log = logs.get(idx).ok_or(ExceptionalHalt::InvalidOpcode)?;
match param {
0x0D => address_to_u256(log.address),
0x0E => U256::from(log.topics.len()),
// 0x0F..=0x12 -> topic0..topic3; halt if the topic is absent.
0x0F..=0x12 => {
// Map the param literal to its topic index directly so
// there is no subtraction to overflow-check.
let n = match param {
0x0F => 0,
0x10 => 1,
0x11 => 2,
_ => 3,
};
let topic = topic_at(log, n).ok_or(ExceptionalHalt::InvalidOpcode)?;
U256::from_big_endian(topic.as_bytes())
}
_ => U256::from(log.data.len()),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 O(n) recomputation on every indexed TXTRACE call

balance_changes, slot_changes, and deployed_contracts are fully recomputed on every opcode invocation — there is no caching across calls. A contract that reads all balance-change addresses (params 0x030x05 × n iterations) incurs O(n) work per call, giving O(n²) total for a full scan, all charged at the fixed 100-gas rate. With a transaction that touches thousands of accounts or slots, this creates an unbounded CPU/time cost for a bounded gas spend. The EIP marks the gas cost TBD and this is draft, but the disparity should be tracked as a known concern before the cost is finalised.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/vm/levm/src/opcode_handlers/tx_trace.rs
Line: 199-261

Comment:
**O(n) recomputation on every indexed TXTRACE call**

`balance_changes`, `slot_changes`, and `deployed_contracts` are fully recomputed on every opcode invocation — there is no caching across calls. A contract that reads all balance-change addresses (params `0x03``0x05` × `n` iterations) incurs O(n) work per call, giving O(n²) total for a full scan, all charged at the fixed 100-gas rate. With a transaction that touches thousands of accounts or slots, this creates an unbounded CPU/time cost for a bounded gas spend. The EIP marks the gas cost TBD and this is draft, but the disparity should be tracked as a known concern before the cost is finalised.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +103 to +112
if let Some(code) = codes.get(&code_hash_after)
&& code_has_delegation(&code.bytecode)?
{
continue;
}
deployed.push((*address, code_hash_after));
}
deployed.sort_by(|a, b| a.0.cmp(&b.0));
Ok(deployed)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Delegation check silently skipped when code is absent from codes map

When codes.get(&code_hash_after) returns None, the if let Some(code) = ... && code_has_delegation(...) guard short-circuits, and the account is included in deployed_contracts without the EIP-7702 delegation check. This is safe only under the implicit invariant that every address that reaches this point (newly deployed, was_empty guard passed) always has its bytecode present in codes. If that invariant is violated — e.g., if a future code path introduces a CREATE that commits the account before storing the code — a delegation designator could silently appear in the deployed list. A debug_assert! or a comment explicitly stating the invariant would make the assumption auditable.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/vm/levm/src/opcode_handlers/tx_trace.rs
Line: 103-112

Comment:
**Delegation check silently skipped when code is absent from `codes` map**

When `codes.get(&code_hash_after)` returns `None`, the `if let Some(code) = ... && code_has_delegation(...)` guard short-circuits, and the account is included in `deployed_contracts` without the EIP-7702 delegation check. This is safe only under the implicit invariant that every address that reaches this point (newly deployed, `was_empty` guard passed) always has its bytecode present in `codes`. If that invariant is violated — e.g., if a future code path introduces a CREATE that commits the account before storing the code — a delegation designator could silently appear in the deployed list. A `debug_assert!` or a comment explicitly stating the invariant would make the assumption auditable.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +912 to +925
// deployed count @0
code.extend({
let mut c = txtrace(0x02, 0x00);
c.extend(push1(0));
c.push(SSTORE);
c
});
// deployed_address @ index 0 -> slot 1
code.extend({
let mut c = txtrace_idx(0x0A, U256::zero());
c.extend(push1(1));
c.push(SSTORE);
c
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing test: pre-existing non-zero slot SSTORE'd to zero should appear in slot_changes

The suite tests restoring a slot to its original value (excluded) and writing new non-zero slots (included), but there is no test for an account that has a non-zero slot in prestate and explicitly sets it to 0. The slot_changes implementation correctly handles this case (since update_account_storage always calls account.storage.insert(key, new_value) even for zero), but the gap means a future regression — such as pruning zero-value entries from storage — would go undetected by the EIP-7906 test suite.

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/tests/levm/eip7906_tests.rs
Line: 912-925

Comment:
**Missing test: pre-existing non-zero slot SSTORE'd to zero should appear in `slot_changes`**

The suite tests restoring a slot to its original value (excluded) and writing new non-zero slots (included), but there is no test for an account that has a non-zero slot in prestate and explicitly sets it to `0`. The `slot_changes` implementation correctly handles this case (since `update_account_storage` always calls `account.storage.insert(key, new_value)` even for zero), but the gap means a future regression — such as pruning zero-value entries from storage — would go undetected by the EIP-7906 test suite.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Adds TXTRACE (0xB5) to enumerate the current transaction's state diff (balance/storage/deploy changes, events, gas pre-charge, gas payer) and EVENTDATACOPY (0xB6) to copy event data into memory. Gated to Hegota alongside EIP-8141. Provisional opcode bytes and gas (EIP marks both TBD).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

levm Lambda EVM implementation

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

1 participant