Skip to content

Commit dbbd986

Browse files
authored
feat: struct-log based trace-frames now include events emitted (#66)
1 parent 1b1cb33 commit dbbd986

7 files changed

Lines changed: 239 additions & 43 deletions

File tree

evm_trace/base.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from eth_pydantic_types import HexBytes
55
from pydantic import BaseModel as _BaseModel
6-
from pydantic import ConfigDict, field_validator
6+
from pydantic import ConfigDict, Field, field_validator
77

88
from evm_trace.display import get_tree_display
99
from evm_trace.enums import CallType
@@ -16,6 +16,31 @@ class BaseModel(_BaseModel):
1616
)
1717

1818

19+
class EventNode(BaseModel):
20+
"""
21+
An event emitted during a CALL.
22+
"""
23+
24+
call_type: CallType = CallType.EVENT
25+
"""The call-type for events is always ``EVENT``."""
26+
27+
data: HexBytes = HexBytes(b"")
28+
"""The remaining event data besides the topics."""
29+
30+
depth: int
31+
"""The depth in a call-tree where the event took place."""
32+
33+
topics: list[HexBytes] = Field(min_length=1)
34+
"""Event topics, including the selector."""
35+
36+
@property
37+
def selector(self) -> HexBytes:
38+
"""
39+
The selector is always the first topic.
40+
"""
41+
return self.topics[0]
42+
43+
1944
class CallTreeNode(BaseModel):
2045
"""
2146
A higher-level object modeling a node in an execution call tree.
@@ -59,6 +84,9 @@ class CallTreeNode(BaseModel):
5984
failed: bool = False
6085
"""Whether the call failed or not."""
6186

87+
events: list[EventNode] = []
88+
"""All events made in the call."""
89+
6290
def __str__(self) -> str:
6391
try:
6492
return get_tree_display(self)

evm_trace/display.py

Lines changed: 63 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,36 @@
11
from collections.abc import Iterator
2-
from typing import TYPE_CHECKING, Optional, cast
2+
from typing import TYPE_CHECKING, Optional, Union, cast
33

44
from eth_typing import ChecksumAddress
55
from eth_utils import to_checksum_address
66

77
from evm_trace.enums import CallType
88

99
if TYPE_CHECKING:
10-
from evm_trace.base import CallTreeNode
10+
from evm_trace.base import CallTreeNode, EventNode
1111

1212

1313
def get_tree_display(call: "CallTreeNode") -> str:
1414
return "\n".join([str(t) for t in TreeRepresentation.make_tree(call)])
1515

1616

1717
class TreeRepresentation:
18-
FILE_MIDDLE_PREFIX = "├──"
19-
FILE_LAST_PREFIX = "└──"
18+
"""
19+
A class for creating a simple tree-representation of a call-tree node.
20+
21+
**NOTE**: We purposely are not using the rich library here to keep
22+
evm-trace small and simple while sill offering a nice stringified
23+
version of a :class:`~evm_trace.base.CallTreeNode`.
24+
"""
25+
26+
MIDDLE_PREFIX = "├──"
27+
LAST_PREFIX = "└──"
2028
PARENT_PREFIX_MIDDLE = " "
2129
PARENT_PREFIX_LAST = "│ "
2230

2331
def __init__(
2432
self,
25-
call: "CallTreeNode",
33+
call: Union["CallTreeNode", "EventNode"],
2634
parent: Optional["TreeRepresentation"] = None,
2735
is_last: bool = False,
2836
):
@@ -32,13 +40,27 @@ def __init__(
3240

3341
@property
3442
def depth(self) -> int:
43+
"""
44+
The depth in the call tree, such as the
45+
number of calls deep.
46+
"""
3547
return self.call.depth
3648

3749
@property
3850
def title(self) -> str:
51+
"""
52+
The title of the node representation, including address, calldata, and return-data.
53+
For event-nodes, it is mostly the selector string.
54+
"""
3955
call_type = self.call.call_type.value
40-
address_hex_str = self.call.address.hex() if self.call.address else None
4156

57+
if hasattr(self.call, "selector"):
58+
# Is an Event-node
59+
selector = self.call.selector.hex() if self.call.selector else None
60+
return f"{call_type}: {selector}"
61+
# else: Is a CallTreeNode
62+
63+
address_hex_str = self.call.address.hex() if self.call.address else None
4264
try:
4365
address = to_checksum_address(address_hex_str) if address_hex_str else None
4466
except (ImportError, ValueError):
@@ -77,33 +99,54 @@ def title(self) -> str:
7799
@classmethod
78100
def make_tree(
79101
cls,
80-
root: "CallTreeNode",
102+
root: Union["CallTreeNode", "EventNode"],
81103
parent: Optional["TreeRepresentation"] = None,
82104
is_last: bool = False,
83105
) -> Iterator["TreeRepresentation"]:
106+
"""
107+
Create a node representation object from a :class:`~evm_trace.base.CallTreeNode`.
108+
109+
Args:
110+
root (:class:`~evm_trace.base.CallTreeNode` | :class:`~evm_trace.base.EventNode`):
111+
The call-tree node or event-node to display.
112+
parent (Optional[:class:`~evm_trace.display.TreeRepresentation`]): The parent
113+
node of this node.
114+
is_last (bool): True if a leaf-node.
115+
"""
84116
displayable_root = cls(root, parent=parent, is_last=is_last)
85117
yield displayable_root
86-
87-
count = 1
88-
for child_node in root.calls:
89-
is_last = count == len(root.calls)
90-
if child_node.calls:
91-
yield from cls.make_tree(child_node, parent=displayable_root, is_last=is_last)
92-
else:
93-
yield cls(child_node, parent=displayable_root, is_last=is_last)
94-
95-
count += 1
118+
if hasattr(root, "topics"):
119+
# Events have no children.
120+
return
121+
122+
# Handle events, which won't have any sub-calls or anything.
123+
total_events = len(root.events)
124+
for index, event in enumerate(root.events, start=1):
125+
is_last = index == total_events
126+
yield cls(event, parent=displayable_root, is_last=is_last)
127+
128+
# Handle calls (and calls of calls).
129+
total_calls = len(root.calls)
130+
for index, child_node in enumerate(root.calls, start=1):
131+
is_last = index == total_calls
132+
# NOTE: `.make_tree()` will handle calls of calls (recursion).
133+
yield from cls.make_tree(child_node, parent=displayable_root, is_last=is_last)
96134

97135
def __str__(self) -> str:
136+
"""
137+
The representation str via ``calling str()``.
138+
"""
98139
if self.parent is None:
99140
return self.title
100141

101-
filename_prefix = self.FILE_LAST_PREFIX if self.is_last else self.FILE_MIDDLE_PREFIX
102-
103-
parts = [f"{filename_prefix} {self.title}"]
142+
tree_prefix = self.LAST_PREFIX if self.is_last else self.MIDDLE_PREFIX
143+
parts = [f"{tree_prefix} {self.title}"]
104144
parent = self.parent
105145
while parent and parent.parent is not None:
106146
parts.append(self.PARENT_PREFIX_MIDDLE if parent.is_last else self.PARENT_PREFIX_LAST)
107147
parent = parent.parent
108148

109149
return "".join(reversed(parts))
150+
151+
def __repr__(self) -> str:
152+
return str(self)

evm_trace/enums.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33

44
class CallType(Enum):
5-
INTERNAL = "INTERNAL" # Non-opcode internal call
5+
CALL = "CALL"
6+
CALLCODE = "CALLCODE"
67
CREATE = "CREATE"
78
CREATE2 = "CREATE2"
8-
CALL = "CALL"
99
DELEGATECALL = "DELEGATECALL"
10-
STATICCALL = "STATICCALL"
11-
CALLCODE = "CALLCODE"
10+
EVENT = "EVENT"
11+
INTERNAL = "INTERNAL" # Non-opcode internal call
1212
SELFDESTRUCT = "SELFDESTRUCT"
13+
STATICCALL = "STATICCALL"
1314

1415
def __eq__(self, other):
1516
return self.value == getattr(other, "value", other)

evm_trace/geth.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from eth_utils import to_int
77
from pydantic import Field, RootModel, field_validator
88

9-
from evm_trace.base import BaseModel, CallTreeNode
9+
from evm_trace.base import BaseModel, CallTreeNode, EventNode
1010
from evm_trace.enums import CALL_OPCODES, CallType
1111

1212

@@ -78,7 +78,7 @@ def create_trace_frames(data: Iterator[dict]) -> Iterator[TraceFrame]:
7878
looking ahead and finding it.
7979
8080
Args:
81-
data (Iterator[Dict]): An iterator of response struct logs.
81+
data (Iterator[dict]): An iterator of response struct logs.
8282
8383
Returns:
8484
Iterator[:class:`~evm_trace.geth.TraceFrame`]
@@ -132,7 +132,7 @@ def get_calltree_from_geth_call_trace(data: dict) -> CallTreeNode:
132132
Creates a CallTreeNode from a given transaction call trace.
133133
134134
Args:
135-
data (Dict): The response from ``debug_traceTransaction`` when using
135+
data (dict): The response from ``debug_traceTransaction`` when using
136136
``tracer=callTracer``.
137137
138138
Returns:
@@ -216,7 +216,7 @@ def extract_memory(offset: HexBytes, size: HexBytes, memory: list[HexBytes]) ->
216216
Args:
217217
offset (HexBytes): Offset byte location in memory.
218218
size (HexBytes): Number of bytes to return.
219-
memory (List[HexBytes]): Memory stack.
219+
memory (list[HexBytes]): Memory stack.
220220
221221
Returns:
222222
HexBytes: Byte value from memory stack.
@@ -252,7 +252,6 @@ def _create_node(
252252
Use specified opcodes to create a branching callnode
253253
https://www.evm.codes/
254254
"""
255-
256255
if isinstance(trace, list):
257256
# NOTE: We don't officially support lists here,
258257
# but if we don't do this, the user gets a recursion error
@@ -298,6 +297,13 @@ def _create_node(
298297
else:
299298
node_kwargs["calls"] = [subcall]
300299

300+
elif frame.op.startswith("LOG") and len(frame.op) > 3 and frame.op[3].isnumeric():
301+
event = _create_event_node(frame)
302+
if "events" in node_kwargs:
303+
node_kwargs["events"].append(event)
304+
else:
305+
node_kwargs["events"] = [event]
306+
301307
# TODO: Handle internal nodes using JUMP and JUMPI
302308

303309
elif frame.op == CallType.SELFDESTRUCT.value:
@@ -324,14 +330,37 @@ def _create_node(
324330
if "last_create_depth" in node_kwargs:
325331
del node_kwargs["last_create_depth"]
326332

333+
if "callType" in node_kwargs:
334+
node_kwargs["call_type"] = node_kwargs.pop("callType")
335+
elif "call_type" not in node_kwargs:
336+
node_kwargs["call_type"] = CallType.CALL # Default.
337+
327338
if node_kwargs["call_type"] in (CallType.CREATE, CallType.CREATE2) and not node_kwargs.get(
328339
"address"
329340
):
330341
# Set temporary address so validation succeeds.
331342
node_kwargs["address"] = 20 * b"\x00"
332343

333-
node = CallTreeNode(**node_kwargs)
334-
return node
344+
return CallTreeNode(**node_kwargs)
345+
346+
347+
def _create_event_node(frame: TraceFrame) -> EventNode:
348+
# The number of topics is derived from the opcode,
349+
# e.g. LOG2 meaning 2 topics (not counting the selector).
350+
num_topics = int(frame.op[3])
351+
352+
# The selector always seems to be here.
353+
selector_idx = -3
354+
selector = frame.stack[-3]
355+
356+
# Figure out topics.
357+
start_topic_idx = selector_idx - num_topics + 1
358+
topics = [selector, *[HexBytes(t) for t in reversed(frame.stack[start_topic_idx:selector_idx])]]
359+
360+
# Figure out data.
361+
data = frame.memory.get(frame.stack[-1], frame.stack[-2])
362+
363+
return EventNode(data=data, depth=frame.depth, topics=topics)
335364

336365

337366
def _validate_data_from_call_tracer(data: dict) -> dict:

tests/conftest.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,20 @@
1010
GETH_DATA = DATA_PATH / "geth"
1111
EVM_TRACE_DATA = DATA_PATH / "evm_trace"
1212
PARITY_DATA = DATA_PATH / "parity"
13-
TRACE_FRAME_DATA = json.loads((EVM_TRACE_DATA / "frame.json").read_text())
14-
CALL_FRAME_DATA = json.loads((EVM_TRACE_DATA / "call.json").read_text())
15-
MUTABLE_CALL_TREE_DATA = json.loads((EVM_TRACE_DATA / "mutable_call.json").read_text())
16-
STATIC_CALL_TREE_DATA = json.loads((EVM_TRACE_DATA / "static_call.json").read_text())
17-
DELEGATE_CALL_TREE_DATA = json.loads((EVM_TRACE_DATA / "delegate_call.json").read_text())
13+
TRACE_FRAME_DATA = json.loads((EVM_TRACE_DATA / "frame.json").read_text(encoding="utf8"))
14+
CALL_FRAME_DATA = json.loads((EVM_TRACE_DATA / "call.json").read_text(encoding="utf8"))
15+
MUTABLE_CALL_TREE_DATA = json.loads(
16+
(EVM_TRACE_DATA / "mutable_call.json").read_text(encoding="utf8")
17+
)
18+
STATIC_CALL_TREE_DATA = json.loads((EVM_TRACE_DATA / "static_call.json").read_text(encoding="utf8"))
19+
DELEGATE_CALL_TREE_DATA = json.loads(
20+
(EVM_TRACE_DATA / "delegate_call.json").read_text(encoding="utf8")
21+
)
1822
CALL_TRACE_DATA = json.loads((GETH_DATA / "call.json").read_text())
19-
CREATE_CALL_TRACE_DATA = json.loads((GETH_DATA / "create_call.json").read_text())
20-
GETH_CREATE2_TRACE = json.loads((GETH_DATA / "create2_structlogs.json").read_text())
21-
PARITY_CREATE2_TRACE = json.loads((PARITY_DATA / "create2.json").read_text())
23+
CREATE_CALL_TRACE_DATA = json.loads((GETH_DATA / "create_call.json").read_text(encoding="utf8"))
24+
GETH_TRACE = json.loads((GETH_DATA / "structlogs.json").read_text(encoding="utf8"))
25+
GETH_CREATE2_TRACE = json.loads((GETH_DATA / "create2_structlogs.json").read_text(encoding="utf8"))
26+
PARITY_CREATE2_TRACE = json.loads((PARITY_DATA / "create2.json").read_text(encoding="utf8"))
2227
CALL_TREE_DATA_MAP = {
2328
CallType.CALL.value: MUTABLE_CALL_TREE_DATA,
2429
CallType.STATICCALL.value: STATIC_CALL_TREE_DATA,
@@ -70,18 +75,23 @@ def call_tree_data(request):
7075
yield CALL_TREE_DATA_MAP[request.param]
7176

7277

73-
@pytest.fixture
78+
@pytest.fixture(scope="session")
7479
def parity_create2_trace_list():
7580
trace_list = [ParityTrace.model_validate(x) for x in PARITY_CREATE2_TRACE]
7681
return ParityTraceList(root=trace_list)
7782

7883

79-
@pytest.fixture
84+
@pytest.fixture(scope="session")
85+
def geth_structlogs():
86+
return GETH_TRACE
87+
88+
89+
@pytest.fixture(scope="session")
8090
def geth_create2_struct_logs():
8191
return GETH_CREATE2_TRACE
8292

8393

84-
@pytest.fixture
94+
@pytest.fixture(scope="session")
8595
def geth_create2_trace_frames(geth_create2_struct_logs):
8696
# NOTE: These frames won't have the CREATE address set.
8797
return [TraceFrame(**x) for x in geth_create2_struct_logs]

tests/data/geth/structlogs.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)