From dae8ce529d7d6f838cb8dad9bf534ea6b8a71f60 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:10:11 +0400 Subject: [PATCH] fix: adopt evm-trace 0.3 and preserve trace gas and event ordering (#2809) Signed-off-by: banteg <4562643+banteg@users.noreply.github.com> --- pyproject.toml | 2 +- src/ape_ethereum/ecosystem.py | 6 +++- src/ape_ethereum/trace.py | 18 ++++++++--- tests/functional/test_ecosystem.py | 9 +++++- tests/functional/test_trace.py | 52 ++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 64fa338216..49f107d426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "eip712>=0.3.0,<0.4", "eth_pydantic_types>=0.2.1,<0.3", "evmchains>=0.1.0,<0.2", - "evm-trace>=0.2.6,<0.3", + "evm-trace>=0.3.0,<0.4", ] [project.scripts] diff --git a/src/ape_ethereum/ecosystem.py b/src/ape_ethereum/ecosystem.py index 324079e54c..d0f70078ef 100644 --- a/src/ape_ethereum/ecosystem.py +++ b/src/ape_ethereum/ecosystem.py @@ -1482,7 +1482,11 @@ def _enrich_trace_event( # Enrich the event-node data using the Ape ContractLog object. log: ContractLog = contract_logs[0] calldata = self._enrich_calldata_dict(log.event_arguments) - return {"name": log.event_name, "calldata": calldata} + enriched = {"name": log.event_name, "calldata": calldata} + if event.get("position") is not None: + enriched["position"] = event["position"] + + return enriched def _enrich_revert_message(self, call: dict) -> dict: returndata = call.get("returndata", "") diff --git a/src/ape_ethereum/trace.py b/src/ape_ethereum/trace.py index e99be62793..d799719b94 100644 --- a/src/ape_ethereum/trace.py +++ b/src/ape_ethereum/trace.py @@ -161,7 +161,7 @@ def enriched_calltree(self) -> dict: # Add top-level data if missing. if ( - not self._enriched_calltree.get("gas_cost") + self._enriched_calltree.get("gas_cost") is None and (gas_used := self.transaction.get("gas_used")) and "data" in self.transaction ): @@ -666,19 +666,27 @@ def _debug_trace_call(self): def parse_rich_tree(call: dict, verbose: bool = False) -> Tree: tree = _create_tree(call, verbose=verbose) + calls = call.get("calls", []) + events_by_position = defaultdict(list) for event in call.get("events", []): if "calldata" not in event and "name" not in event: # Not sure; or not worth showing. logger.debug(f"Unknown event data: '{event}'.") continue - event_tree = _create_event_tree(event) - tree.add(event_tree) + # Position counts preceding child calls. Older traces omit it. + position = min(event.get("position") or 0, len(calls)) + events_by_position[position].append(event) - for sub_call in call.get("calls", []): + for index, sub_call in enumerate(calls): + for event in events_by_position[index]: + tree.add(_create_event_tree(event)) sub_tree = parse_rich_tree(sub_call, verbose=verbose) tree.add(sub_tree) + for event in events_by_position[len(calls)]: + tree.add(_create_event_tree(event)) + return tree @@ -754,7 +762,7 @@ def _call_to_str(call: dict, stylize: bool = False, verbose: bool = False) -> st signature += f" {value}" - if call.get("gas_cost"): + if call.get("gas_cost") is not None: gas_value = f"[{call['gas_cost']} gas]" if stylize: gas_value = f"[{TraceStyles.GAS_COST}]{gas_value}[/]" diff --git a/tests/functional/test_ecosystem.py b/tests/functional/test_ecosystem.py index a847453501..5bea1a497f 100644 --- a/tests/functional/test_ecosystem.py +++ b/tests/functional/test_ecosystem.py @@ -1190,7 +1190,8 @@ def get_calltree(self) -> CallTreeNode: assert actual["call_type"] == CallType.CALL.value -def test_enrich_trace_handles_events(ethereum, vyper_contract_instance, owner): +@pytest.mark.parametrize("position", (None, 0, 1)) +def test_enrich_trace_handles_events(ethereum, vyper_contract_instance, owner, position): tx = vyper_contract_instance.setNumber(96247783, sender=owner) # Used Hardhat to get the data. @@ -1207,6 +1208,9 @@ def test_enrich_trace_handles_events(ethereum, vyper_contract_instance, owner): } ] + if position is not None: + events[0]["position"] = position + calldata = "0x3fb5c1cb000000000000000000000000000000000000000000000000000000000000007b" call = { "events": events, @@ -1235,6 +1239,9 @@ def get_calltree(self) -> CallTreeNode: }, } ] + if position is not None: + expected[0]["position"] = position + assert events == expected diff --git a/tests/functional/test_trace.py b/tests/functional/test_trace.py index 44c440a591..6b1ad25adc 100644 --- a/tests/functional/test_trace.py +++ b/tests/functional/test_trace.py @@ -1,9 +1,11 @@ import json import re +from io import StringIO import pytest from evm_trace import CallTreeNode, CallType from hexbytes import HexBytes +from rich.console import Console from ape_ethereum.trace import CallTrace, Trace, TraceApproach, TransactionTrace, parse_rich_tree from tests.functional.data.python import ( @@ -100,6 +102,45 @@ def test_parse_rich_tree_handle_bad_event_data(): assert len(tree.children) == 0 +@pytest.mark.parametrize("final_position", (2, 99)) +def test_parse_rich_tree_event_positions(final_position): + data = { + "method_id": "root", + "events": [ + {"name": "Before", "position": 0}, + {"name": "Between", "position": 1}, + {"name": "AlsoBetween", "position": 1}, + {"name": "After", "position": final_position}, + {"name": "Legacy"}, + {"name": "UnknownPosition", "position": None}, + ], + "calls": [ + { + "method_id": "first", + "calls": [{"method_id": "nested"}], + "events": [{"name": "NestedAfter", "position": 1}], + }, + {"method_id": "second"}, + ], + } + output = StringIO() + Console(file=output, color_system=None).print(parse_rich_tree(data)) + lines = [line.strip(" │├└─") for line in output.getvalue().splitlines() if line.strip()] + assert lines == [ + "root()", + "log Before()", + "log Legacy()", + "log UnknownPosition()", + "first()", + "nested()", + "log NestedAfter()", + "log Between()", + "log AlsoBetween()", + "second()", + "log After()", + ] + + def test_get_gas_report(gas_tracker, owner, vyper_contract_instance): tx = vyper_contract_instance.setNumber(924, sender=owner) trace = tx.trace @@ -343,6 +384,17 @@ def test_enriched_calltree_adds_missing_gas(simple_trace_cls): assert actual["gas_cost"] == compute_gas +@pytest.mark.parametrize("gas_cost", (0, 42)) +def test_enriched_calltree_preserves_known_gas(simple_trace_cls, gas_cost): + trace_cls = simple_trace_cls( + {**PASSING_TRACE, "gas_cost": gas_cost}, + tx={"gas_used": 25_000, "data": HexBytes("0x12345678")}, + ) + trace = trace_cls.model_validate(TRACE_API_DATA) + assert trace.enriched_calltree["gas_cost"] == gas_cost + assert f"[{gas_cost} gas]" in str(trace) + + class TestTraceApproach: @pytest.mark.parametrize( "key",