Skip to content

Commit

Permalink
feat: show events in pretty call-trace or from receipt (#2161)
Browse files Browse the repository at this point in the history
  • Loading branch information
antazoey authored Jun 27, 2024
1 parent 2dc9b10 commit 9d40bab
Show file tree
Hide file tree
Showing 13 changed files with 378 additions and 25 deletions.
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
6 changes: 6 additions & 0 deletions src/ape/api/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 18 additions & 4 deletions src/ape/managers/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
84 changes: 81 additions & 3 deletions src/ape_ethereum/ecosystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
DecodingError,
SignatureError,
)
from ape.logging import logger
from ape.managers.config import merge_configs
from ape.types import (
AddressType,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"] = ""
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion src/ape_ethereum/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[])
Expand Down Expand Up @@ -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)

Expand Down
87 changes: 87 additions & 0 deletions src/ape_ethereum/trace.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -526,13 +569,48 @@ 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)

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", "")
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion src/ape_ethereum/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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[
Expand Down
Loading

0 comments on commit 9d40bab

Please sign in to comment.