diff --git a/setup.py b/setup.py index 060e465354..25811454c7 100644 --- a/setup.py +++ b/setup.py @@ -134,7 +134,7 @@ "ethpm-types>=0.6.9,<0.7", "eth_pydantic_types>=0.1.0,<0.2", "evmchains>=0.0.10,<0.1", - "evm-trace>=0.1.5,<0.2", + "evm-trace>=0.2.0,<0.3", ], entry_points={ "console_scripts": ["ape=ape._cli:cli"], diff --git a/src/ape/api/transactions.py b/src/ape/api/transactions.py index c29f51ad15..663e60a9dc 100644 --- a/src/ape/api/transactions.py +++ b/src/ape/api/transactions.py @@ -534,6 +534,12 @@ def show_source_traceback(self): like in local projects. """ + @raises_not_implemented + def show_events(self): + """ + Show the events from the receipt. + """ + def track_gas(self): """ Track this receipt's gas in the on-going session gas-report. diff --git a/src/ape/managers/chain.py b/src/ape/managers/chain.py index 75ccaa9b1d..97145de60b 100644 --- a/src/ape/managers/chain.py +++ b/src/ape/managers/chain.py @@ -1429,17 +1429,31 @@ def show_gas(self, report: GasReport, file: Optional[IO[str]] = None): self.echo(*tables, file=file) - def echo(self, *rich_items, file: Optional[IO[str]] = None): - console = self._get_console(file=file) + def echo( + self, *rich_items, file: Optional[IO[str]] = None, console: Optional[RichConsole] = None + ): + console = console or self._get_console(file) console.print(*rich_items) def show_source_traceback( - self, traceback: SourceTraceback, file: Optional[IO[str]] = None, failing: bool = True + self, + traceback: SourceTraceback, + file: Optional[IO[str]] = None, + console: Optional[RichConsole] = None, + failing: bool = True, ): - console = self._get_console(file) + console = console or self._get_console(file) style = "red" if failing else None console.print(str(traceback), style=style) + def show_events( + self, events: list, file: Optional[IO[str]] = None, console: Optional[RichConsole] = None + ): + console = console or self._get_console(file) + console.print("Events emitted:") + for event in events: + console.print(event) + def _get_console(self, file: Optional[IO[str]] = None) -> RichConsole: if not file: return get_console() diff --git a/src/ape_ethereum/ecosystem.py b/src/ape_ethereum/ecosystem.py index f6fdacfb98..29afe01543 100644 --- a/src/ape_ethereum/ecosystem.py +++ b/src/ape_ethereum/ecosystem.py @@ -33,6 +33,7 @@ DecodingError, SignatureError, ) +from ape.logging import logger from ape.managers.config import merge_configs from ape.types import ( AddressType, @@ -1116,6 +1117,11 @@ def _enrich_calltree(self, call: dict, **kwargs) -> dict: # Without a contract, we can enrich no further. return call + if events := call.get("events"): + call["events"] = self._enrich_trace_events( + events, address=address, contract_type=contract_type + ) + method_abi: Optional[Union[MethodABI, ConstructorABI]] = None if is_create: method_abi = contract_type.constructor @@ -1221,12 +1227,13 @@ def _enrich_calldata( except DecodingError: call["calldata"] = ["" for _ in method_abi.inputs] else: - call["calldata"] = { - k: self._enrich_value(v, **kwargs) for k, v in call["calldata"].items() - } + call["calldata"] = self._enrich_calldata_dict(call["calldata"], **kwargs) return call + def _enrich_calldata_dict(self, calldata: dict, **kwargs) -> dict: + return {k: self._enrich_value(v, **kwargs) for k, v in calldata.items()} + def _enrich_returndata(self, call: dict, method_abi: MethodABI, **kwargs) -> dict: if "CREATE" in call.get("call_type", ""): call["returndata"] = "" @@ -1303,6 +1310,77 @@ def _enrich_returndata(self, call: dict, method_abi: MethodABI, **kwargs) -> dic call["returndata"] = output_val return call + def _enrich_trace_events( + self, + events: list[dict], + address: Optional[AddressType] = None, + contract_type: Optional[ContractType] = None, + ) -> list[dict]: + return [ + self._enrich_trace_event(e, address=address, contract_type=contract_type) + for e in events + ] + + def _enrich_trace_event( + self, + event: dict, + address: Optional[AddressType] = None, + contract_type: Optional[ContractType] = None, + ) -> dict: + if "topics" not in event or len(event["topics"]) < 1: + # Already enriched or wrong. + return event + + elif not address: + address = event.get("address") + if not address: + # Cannot enrich further w/o an address. + return event + + if not contract_type: + try: + contract_type = self.chain_manager.contracts.get(address) + except Exception as err: + logger.debug(f"Error getting contract type during event enrichment: {err}") + return event + + if not contract_type: + # Cannot enrich further w/o an contract type. + return event + + # The selector is always the first topic. + selector = event["topics"][0] + if not isinstance(selector, str): + selector = selector.hex() + + if selector not in contract_type.identifier_lookup: + # Unable to enrich using this contract type. + # Selector unknown. + return event + + abi = contract_type.identifier_lookup[selector] + assert isinstance(abi, EventABI) # For mypy. + log_data = { + "topics": event["topics"], + "data": event["data"], + "address": address, + } + + try: + contract_logs = [log for log in self.decode_logs([log_data], abi)] + except Exception as err: + logger.debug(f"Failed decoding logs from trace data: {err}") + return event + + if not contract_logs: + # Not sure if this is a likely condition. + return 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} + def _enrich_revert_message(self, call: dict) -> dict: returndata = call.get("returndata", "") is_hexstr = isinstance(returndata, str) and is_0x_prefixed(returndata) diff --git a/src/ape_ethereum/provider.py b/src/ape_ethereum/provider.py index ec19e91535..12837f3595 100644 --- a/src/ape_ethereum/provider.py +++ b/src/ape_ethereum/provider.py @@ -241,6 +241,19 @@ def base_fee(self) -> int: return pending_base_fee + @property + def call_trace_approach(self) -> Optional[TraceApproach]: + """ + The default tracing approach to use when building up a call-tree. + By default, Ape attempts to use the faster approach. Meaning, if + geth-call-tracer or parity are available, Ape will use one of those + instead of building a call-trace entirely from struct-logs. + """ + if approach := self._call_trace_approach: + return approach + + return self.settings.get("call_trace_approach") + def _get_fee_history(self, block_number: int) -> FeeHistory: try: return self.web3.eth.fee_history(1, BlockNumber(block_number), reward_percentiles=[]) @@ -407,7 +420,7 @@ def get_storage( def get_transaction_trace(self, transaction_hash: str, **kwargs) -> TraceAPI: if "call_trace_approach" not in kwargs: - kwargs["call_trace_approach"] = self._call_trace_approach + kwargs["call_trace_approach"] = self.call_trace_approach return TransactionTrace(transaction_hash=transaction_hash, **kwargs) diff --git a/src/ape_ethereum/trace.py b/src/ape_ethereum/trace.py index 9ad613f413..486f2a3354 100644 --- a/src/ape_ethereum/trace.py +++ b/src/ape_ethereum/trace.py @@ -1,6 +1,7 @@ import json import sys from abc import abstractmethod +from collections import defaultdict from collections.abc import Iterable, Iterator, Sequence from enum import Enum from functools import cached_property @@ -54,6 +55,30 @@ class TraceApproach(Enum): NOT RECOMMENDED. """ + @classmethod + def from_key(cls, key: str) -> "TraceApproach": + return cls(cls._validate(key)) + + @classmethod + def _validate(cls, key: Any) -> "TraceApproach": + if isinstance(key, TraceApproach): + return key + elif isinstance(key, int) or (isinstance(key, str) and key.isnumeric()): + return cls(int(key)) + + # Check if given a name. + key = key.replace("-", "_").upper() + + # Allow shorter, nicer values for the geth-struct-log approach. + if key in ("GETH", "GETH_STRUCT_LOG", "GETH_STRUCT_LOGS"): + key = "GETH_STRUCT_LOG_PARSE" + + for member in cls: + if member.name == key: + return member + + raise ValueError(f"No enum named '{key}'.") + class Trace(TraceAPI): """ @@ -218,6 +243,8 @@ def try_get_revert_msg(c) -> Optional[str]: def show(self, verbose: bool = False, file: IO[str] = sys.stdout): call = self.enriched_calltree + approaches_handling_events = (TraceApproach.GETH_STRUCT_LOG_PARSE,) + failed = call.get("failed", False) revert_message = None if failed: @@ -240,6 +267,22 @@ def show(self, verbose: bool = False, file: IO[str] = sys.stdout): if sender := self.transaction.get("from"): console.print(f"tx.origin=[{TraceStyles.CONTRACTS}]{sender}[/]") + if self.call_trace_approach not in approaches_handling_events and hasattr( + self._ecosystem, "_enrich_trace_events" + ): + # We must manually attach the contract logs. + # NOTE: With these approaches, we don't know where they appear + # in the call-tree so we have to put them at the top. + if logs := self.transaction.get("logs", []): + enriched_events = self._ecosystem._enrich_trace_events(logs) + event_trees = _events_to_trees(enriched_events) + if event_trees: + console.print() + self.chain_manager._reports.show_events(event_trees, console=console) + console.print() + + # else: the events are already included in the right spots in the call tree. + console.print(root) def get_gas_report(self, exclude: Optional[Sequence[ContractFunctionPath]] = None) -> GasReport: @@ -526,6 +569,10 @@ def _debug_trace_call(self): def parse_rich_tree(call: dict, verbose: bool = False) -> Tree: tree = _create_tree(call, verbose=verbose) + for event in call.get("events", []): + event_tree = _create_event_tree(event) + tree.add(event_tree) + for sub_call in call["calls"]: sub_tree = parse_rich_tree(sub_call, verbose=verbose) tree.add(sub_tree) @@ -533,6 +580,37 @@ def parse_rich_tree(call: dict, verbose: bool = False) -> Tree: return tree +def _events_to_trees(events: list[dict]) -> list[Tree]: + event_counter = defaultdict(list) + for evt in events: + name = evt.get("name") + calldata = evt.get("calldata") + + if not name or not calldata: + continue + + tuple_key = ( + name, + ",".join(f"{k}={v}" for k, v in calldata.items()), + ) + event_counter[tuple_key].append(evt) + + result = [] + for evt_tup, events in event_counter.items(): + count = len(events) + # NOTE: Using similar style to gas-cost on purpose. + suffix = f"[[{TraceStyles.GAS_COST}]x{count}[/]]" if count > 1 else "" + evt_tree = _create_event_tree(events[0], suffix=suffix) + result.append(evt_tree) + + return result + + +def _create_event_tree(event: dict, suffix: str = "") -> Tree: + signature = _event_to_str(event, stylize=True, suffix=suffix) + return Tree(signature) + + def _call_to_str(call: dict, stylize: bool = False, verbose: bool = False) -> str: contract = str(call.get("contract_id", "")) is_create = "CREATE" in call.get("call_type", "") @@ -592,6 +670,15 @@ def _call_to_str(call: dict, stylize: bool = False, verbose: bool = False) -> st return signature +def _event_to_str(event: dict, stylize: bool = False, suffix: str = "") -> str: + # NOTE: Some of the styles are matching others parts of the trace, + # even though the 'name' is a bit misleading. + name = f"[{TraceStyles.METHODS}]{event['name']}[/]" if stylize else event["name"] + arguments_str = _get_inputs_str(event.get("calldata"), stylize=stylize) + prefix = f"[{TraceStyles.CONTRACTS}]log[/]" if stylize else "log" + return f"{prefix} {name}{arguments_str}{suffix}" + + def _create_tree(call: dict, verbose: bool = False) -> Tree: signature = _call_to_str(call, stylize=True, verbose=verbose) return Tree(signature) diff --git a/src/ape_ethereum/transactions.py b/src/ape_ethereum/transactions.py index 9097ad19c5..ffc1cbf1c9 100644 --- a/src/ape_ethereum/transactions.py +++ b/src/ape_ethereum/transactions.py @@ -21,7 +21,7 @@ from ape.logging import logger from ape.types import AddressType, ContractLog, ContractLogContainer, SourceTraceback from ape.utils import ZERO_ADDRESS -from ape_ethereum.trace import Trace +from ape_ethereum.trace import Trace, _events_to_trees class TransactionStatusEnum(IntEnum): @@ -273,6 +273,16 @@ def show_source_traceback(self, file: IO[str] = sys.stdout): self.source_traceback, file=file, failing=self.failed ) + def show_events(self, file: IO[str] = sys.stdout): + if provider := self.network_manager.active_provider: + ecosystem = provider.network.ecosystem + else: + ecosystem = self.network_manager.ethereum + + enriched_events = ecosystem._enrich_trace_events(self.logs) + event_trees = _events_to_trees(enriched_events) + self.chain_manager._reports.show_events(event_trees, file=file) + def decode_logs( self, abi: Optional[ diff --git a/src/ape_node/provider.py b/src/ape_node/provider.py index 7566021108..533a2c0a33 100644 --- a/src/ape_node/provider.py +++ b/src/ape_node/provider.py @@ -12,6 +12,7 @@ from geth.chain import initialize_chain # type: ignore from geth.process import BaseGethProcess # type: ignore from geth.wrapper import construct_test_chain_kwargs # type: ignore +from pydantic import field_validator from pydantic_settings import SettingsConfigDict from requests.exceptions import ConnectionError from web3.middleware import geth_poa_middleware @@ -37,6 +38,7 @@ DEFAULT_SETTINGS, EthereumNodeProvider, ) +from ape_ethereum.trace import TraceApproach class GethDevProcess(BaseGethProcess): @@ -204,13 +206,50 @@ class EthereumNetworkConfig(PluginConfig): class EthereumNodeConfig(PluginConfig): + """ + Configure your ``node:`` in Ape, the default provider + plugin for live-network nodes. Also, ``ape node`` can + start-up a local development node for testing purposes. + """ + ethereum: EthereumNetworkConfig = EthereumNetworkConfig() + """ + Configure the Ethereum network settings for the ``ape node`` provider, + such as which URIs to use for each network. + """ + executable: Optional[str] = None - ipc_path: Optional[Path] = None + """ + For starting nodes, select the executable. Defaults to using + ``shutil.which("geth")``. + """ + data_dir: Optional[Path] = None + """ + For node-management, choose where the geth data directory shall + be located. Defaults to using a location within Ape's DATA_FOLDER. + """ + + ipc_path: Optional[Path] = None + """ + For IPC connections, select the IPC path. If managing a process, + web3.py can determine the IPC w/o needing to manually configure. + """ + + call_trace_approach: Optional[TraceApproach] = None + """ + Select the trace approach to use. Defaults to deducing one + based on your node's client-version and available RPCs. + """ model_config = SettingsConfigDict(extra="allow") + @field_validator("call_trace_approach", mode="before") + @classmethod + def validate_trace_approach(cls, value): + # This handles nicer config values. + return None if value is None else TraceApproach.from_key(value) + class NodeSoftwareNotInstalledError(ConnectionError): def __init__(self): diff --git a/tests/functional/data/sources/VyperContract.vy b/tests/functional/data/sources/VyperContract.vy index d36ad57c64..8ab0b3b3af 100644 --- a/tests/functional/data/sources/VyperContract.vy +++ b/tests/functional/data/sources/VyperContract.vy @@ -31,6 +31,7 @@ event EventWithUintArray: struct MyStruct: a: address b: bytes32 + c: uint256 struct NestedStruct1: t: MyStruct @@ -104,22 +105,22 @@ def getStruct() -> MyStruct: @view @external def getNestedStruct1() -> NestedStruct1: - return NestedStruct1({t: MyStruct({a: msg.sender, b: block.prevhash}), foo: 1}) + return NestedStruct1({t: MyStruct({a: msg.sender, b: block.prevhash, c: 244}), foo: 1}) @view @external def getNestedStruct2() -> NestedStruct2: - return NestedStruct2({foo: 2, t: MyStruct({a: msg.sender, b: block.prevhash})}) + return NestedStruct2({foo: 2, t: MyStruct({a: msg.sender, b: block.prevhash, c: 244})}) @view @external def getNestedStructWithTuple1() -> (NestedStruct1, uint256): - return (NestedStruct1({t: MyStruct({a: msg.sender, b: block.prevhash}), foo: 1}), 1) + return (NestedStruct1({t: MyStruct({a: msg.sender, b: block.prevhash, c: 244}), foo: 1}), 1) @view @external def getNestedStructWithTuple2() -> (uint256, NestedStruct2): - return (2, NestedStruct2({foo: 2, t: MyStruct({a: msg.sender, b: block.prevhash})})) + return (2, NestedStruct2({foo: 2, t: MyStruct({a: msg.sender, b: block.prevhash, c: 244})})) @pure @external @@ -161,8 +162,8 @@ def getStructWithArray() -> WithArray: { foo: 1, arr: [ - MyStruct({a: msg.sender, b: block.prevhash}), - MyStruct({a: msg.sender, b: block.prevhash}) + MyStruct({a: msg.sender, b: block.prevhash, c: 244}), + MyStruct({a: msg.sender, b: block.prevhash, c: 244}) ], bar: 2 } @@ -192,16 +193,16 @@ def getAddressArray() -> DynArray[address, 2]: @external def getDynamicStructArray() -> DynArray[NestedStruct1, 2]: return [ - NestedStruct1({t: MyStruct({a: msg.sender, b: block.prevhash}), foo: 1}), - NestedStruct1({t: MyStruct({a: msg.sender, b: block.prevhash}), foo: 2}) + NestedStruct1({t: MyStruct({a: msg.sender, b: block.prevhash, c: 244}), foo: 1}), + NestedStruct1({t: MyStruct({a: msg.sender, b: block.prevhash, c: 244}), foo: 2}) ] @view @external def getStaticStructArray() -> NestedStruct2[2]: return [ - NestedStruct2({foo: 1, t: MyStruct({a: msg.sender, b: block.prevhash})}), - NestedStruct2({foo: 2, t: MyStruct({a: msg.sender, b: block.prevhash})}) + NestedStruct2({foo: 1, t: MyStruct({a: msg.sender, b: block.prevhash, c: 244})}), + NestedStruct2({foo: 2, t: MyStruct({a: msg.sender, b: block.prevhash, c: 244})}) ] @pure @@ -288,7 +289,8 @@ def logStruct(): _bytes: bytes32 = 0x1234567890abcdef0123456789abcdef0123456789abcdef0123456789abcdef _struct: MyStruct = MyStruct({ a: msg.sender, - b: _bytes + b: _bytes, + c: 244 }) log EventWithStruct(_struct) diff --git a/tests/functional/geth/test_provider.py b/tests/functional/geth/test_provider.py index 6209de9c3c..bc06e9850f 100644 --- a/tests/functional/geth/test_provider.py +++ b/tests/functional/geth/test_provider.py @@ -23,6 +23,7 @@ from ape.utils import to_int from ape_ethereum.ecosystem import Block from ape_ethereum.provider import DEFAULT_SETTINGS, EthereumNodeProvider +from ape_ethereum.trace import TraceApproach from ape_ethereum.transactions import ( AccessList, AccessListTransaction, @@ -592,3 +593,12 @@ def test_get_virtual_machine_error(geth_provider): error = Web3ContractLogicError(f"execution reverted: {expected}", "0x08c379a") actual = geth_provider.get_virtual_machine_error(error) assert actual.message == expected + + +@geth_process_test +def test_trace_approach_config(project): + node_cfg = project.config.node.model_dump(by_alias=True) + node_cfg["call_trace_approach"] = "geth" + with project.temp_config(node=node_cfg): + provider = project.network_manager.ethereum.local.get_provider("node") + assert provider.call_trace_approach is TraceApproach.GETH_STRUCT_LOG_PARSE diff --git a/tests/functional/test_ecosystem.py b/tests/functional/test_ecosystem.py index 09027695ae..d255c23ab1 100644 --- a/tests/functional/test_ecosystem.py +++ b/tests/functional/test_ecosystem.py @@ -1077,3 +1077,51 @@ def get_calltree(self) -> CallTreeNode: call = trace.get_calltree().model_dump(by_alias=True) actual = ethereum._enrich_calltree(call) assert actual["call_type"] == CallType.CALL.value + + +def test_enrich_trace_handles_events(ethereum, vyper_contract_instance, owner): + tx = vyper_contract_instance.setNumber(96247783, sender=owner) + + # Used Hardhat to get the data. + events = [ + { + "call_type": "EVENT", + "data": "0x3ee0dda47a082585c3c66434c4b5b1b4558581efb5f4ee60a59a58bc1201404b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000744796e616d696300000000000000000000000000000000000000000000000000", # noqa: E501 + "depth": 1, + "topics": [ + "0xa84473122c11e32cd505595f246a28418b8ecd6cf819f4e3915363fad1b8f968", + "0x000000000000000000000000000000000000000000000000000000000000007b", + "0x9f3d45ac20ccf04b45028b8080bb191eab93e29f7898ed43acf480dd80bba94d", + ], + } + ] + + calldata = "0x3fb5c1cb000000000000000000000000000000000000000000000000000000000000007b" + call = { + "events": events, + "call_type": "CALL", + "address": vyper_contract_instance.address, + "calldata": calldata, + } + + class MyTrace(TransactionTrace): + def get_calltree(self) -> CallTreeNode: + return CallTreeNode.model_validate(call) + + trace = MyTrace(transaction_hash=tx.txn_hash) + actual = ethereum.enrich_trace(trace) + assert "events" in actual._enriched_calltree, "is evm-trace updated?" + events = actual._enriched_calltree["events"] + expected = [ + { + "name": "NumberChange", + "calldata": { + "b": "0x3e..404b", + "prevNum": 0, + "dynData": '"Dynamic"', + "newNum": 123, + "dynIndexed": "0x9f..a94d", + }, + } + ] + assert events == expected diff --git a/tests/functional/test_receipt.py b/tests/functional/test_receipt.py index 73814c3822..148cc5dbf1 100644 --- a/tests/functional/test_receipt.py +++ b/tests/functional/test_receipt.py @@ -39,9 +39,30 @@ def test_receipt_properties(chain, invoke_receipt): def test_show_trace(trace_print_capture, invoke_receipt): invoke_receipt.show_trace() - actual = trace_print_capture.call_args[0][0] - assert isinstance(actual, Tree) - label = f"{actual.label}" + call_args = trace_print_capture.call_args_list + + # Trace title assertion. + actual_title = call_args[0][0][0] + assert actual_title == f"Call trace for [bold blue]'{invoke_receipt.txn_hash}'[/]" + + # The origin comes below the title. + actual_origin = call_args[1][0][0] + assert actual_origin == f"tx.origin=[#ff8c00]{invoke_receipt.sender}[/]" + + # Next are events. + events_title = call_args[3][0][0] + event_tree = call_args[4][0][0] + event_label = str(event_tree.label) + assert events_title == "Events emitted:" + assert isinstance(event_tree, Tree) + assert "[bright_green]NumberChange" in event_label + assert 'dynData=[bright_magenta]"Dynamic"' in event_label + assert "newNum=[bright_magenta]1" in event_label + + # Assert stuff about actual call-tree now. + actual_calltree = call_args[6][0][0] + assert isinstance(actual_calltree, Tree) + label = f"{actual_calltree.label}" assert "VyperContract" in label assert "setNumber" in label assert f"[{invoke_receipt.gas_used} gas]" in label @@ -54,6 +75,17 @@ def test_show_gas_report(trace_print_capture, invoke_receipt): assert actual.title == "VyperContract Gas" +def test_show_events(trace_print_capture, invoke_receipt): + invoke_receipt.show_events() + actual = trace_print_capture.call_args[0][0] + assert isinstance(actual, Tree) + label = str(actual.label) + # Formatted and enriched signature string. + assert "[bright_green]NumberChange" in label + assert 'dynData=[bright_magenta]"Dynamic"' in label + assert "newNum=[bright_magenta]1" in label + + def test_decode_logs_specify_abi(invoke_receipt, vyper_contract_instance): abi = vyper_contract_instance.NumberChange.abi logs = invoke_receipt.decode_logs(abi=abi) diff --git a/tests/functional/test_trace.py b/tests/functional/test_trace.py index befb1af33a..19c14b6d42 100644 --- a/tests/functional/test_trace.py +++ b/tests/functional/test_trace.py @@ -231,3 +231,17 @@ def test_enriched_calltree_adds_missing_gas(simple_trace_cls): trace = trace_cls.model_validate(TRACE_API_DATA) actual = trace.enriched_calltree assert actual["gas_cost"] == compute_gas + + +class TestTraceApproach: + @pytest.mark.parametrize( + "key", + ("geth", "geth-struct-log", "GETH_STRUCT_LOGS", TraceApproach.GETH_STRUCT_LOG_PARSE.value), + ) + def test_from_key_geth_struct_log(self, key): + actual = TraceApproach.from_key(key) + assert actual == TraceApproach.GETH_STRUCT_LOG_PARSE + + def test_from_key_parity(self): + actual = TraceApproach.from_key("parity") + assert actual == TraceApproach.PARITY