Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: default displayable trace in repr and str for CallTreeNode #5

Merged
merged 4 commits into from
Jun 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
rev: v4.2.0
hooks:
- id: check-yaml

- repo: https://github.com/pre-commit/mirrors-isort
rev: v5.9.3
rev: v5.10.1
hooks:
- id: isort

- repo: https://github.com/psf/black
rev: 21.10b0
rev: 22.3.0
hooks:
- id: black
name: black

- repo: https://gitlab.com/pycqa/flake8
rev: 3.9.2
rev: 4.0.1
hooks:
- id: flake8

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.910-1
rev: v0.960
hooks:
- id: mypy
additional_dependencies: [types-PyYAML, types-requests]
Expand Down
48 changes: 46 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,52 @@ python3 setup.py install
```

## Quick Usage
```bash
ape console --network ethereum:local:hardhat

If you are using a node that supports the `debug_traceTransaction` RPC, you can use `web3.py` to get trace frames:

```python
from web3 import HTTPProvider, Web3
from evm_trace import TraceFrame

web3 = Web3(HTTPProvider("https://path.to.my.node"))
struct_logs = web3.manager.request_blocking("debug_traceTransaction", [txn_hash]).structLogs
for item in struct_logs:
yield TraceFrame(**item)
```

If you want to get the call-tree node, you can do:

```python
from evm_trace import CallType, get_calltree_from_trace

root_node_kwargs = {
"gas_cost": 10000000,
"gas_limit": 10000000000,
"address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
"calldata": "0x00",
"value": 1000,
"call_type": CallType.MUTABLE,
}

# Where `trace` is a `TraceFrame` (see example above)
calltree = get_calltree_from_trace(trace, **root_node_kwargs)
```

You can also customize the output by making your own display class:

```python
from evm_trace.display import DisplayableCallTreeNode, get_calltree_from_trace


class CustomDisplay(DisplayableCallTreeNode):
def title(self) -> str:
call_type = self.call.call_type.value.lower().capitalize()
address = self.call.address.hex()
cost = self.call.gas_cost
return f"{call_type} call @ {address} gas_cost={cost}"


calltree = get_calltree_from_trace(trace, display_cls=CustomDisplay)
```

## Development
Expand Down
69 changes: 42 additions & 27 deletions evm_trace/base.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import math
from enum import Enum
from typing import Any, Dict, Iterator, List
from typing import Any, Dict, Iterable, Iterator, List, Type

from eth_utils import to_int
from hexbytes import HexBytes
from pydantic import BaseModel, Field, ValidationError, validator

from evm_trace.display import DisplayableCallTreeNode
from evm_trace.enums import CallType


def _convert_hexbytes(cls, v: Any) -> HexBytes:
try:
Expand Down Expand Up @@ -33,14 +35,6 @@ def convert_hexbytes_dict(cls, v) -> Dict[HexBytes, HexBytes]:
return {_convert_hexbytes(cls, k): _convert_hexbytes(cls, val) for k, val in v.items()}


class CallType(Enum):
INTERNAL = "INTERNAL" # Non-opcode internal call
STATIC = "STATIC" # STATICCALL opcode
MUTABLE = "MUTABLE" # CALL opcode
DELEGATE = "DELEGATE" # DELEGATECALL opcode
SELFDESTRUCT = "SELFDESTRUCT" # SELFDESTRUCT opcode


class CallTreeNode(BaseModel):
call_type: CallType
address: Any
Expand All @@ -53,11 +47,25 @@ class CallTreeNode(BaseModel):
calls: List["CallTreeNode"] = []
selfdestruct: bool = False
failed: bool = False
display_cls: Type[DisplayableCallTreeNode] = DisplayableCallTreeNode

@property
def display_nodes(self) -> Iterable[DisplayableCallTreeNode]:
return self.display_cls.make_tree(self)

@validator("address", "calldata", "returndata", pre=True)
def validate_hexbytes(cls, v) -> HexBytes:
return _convert_hexbytes(cls, v)

def __str__(self) -> str:
return "\n".join([str(t) for t in self.display_nodes])

def __repr__(self) -> str:
return str(self)

def __getitem__(self, index: int) -> "CallTreeNode":
return self.calls[index]


def get_calltree_from_trace(
trace: Iterator[TraceFrame], show_internal=False, **root_node_kwargs
Expand All @@ -68,17 +76,18 @@ def get_calltree_from_trace(
Args:
trace (Iterator[TraceFrame]): Iterator of transaction trace frames.
show_internal (bool): Boolean whether to display internal calls. Defaulted to False.
root_node_kwargs (dict): Keyword argments passed to the root ``CallTreeNode``.
root_node_kwargs (dict): Keyword arguments passed to the root ``CallTreeNode``.

Returns:
:class:`~evm_trace.base.CallTreeNode: Call tree of transaction trace.
:class:`~evm_trace.base.CallTreeNode`: Call tree of transaction trace.
"""

return _create_node_from_call(
node = _create_node_from_call(
trace=trace,
show_internal=show_internal,
**root_node_kwargs,
)
return node


def _extract_memory(offset: HexBytes, size: HexBytes, memory: List[HexBytes]) -> HexBytes:
Expand Down Expand Up @@ -106,13 +115,15 @@ def _extract_memory(offset: HexBytes, size: HexBytes, memory: List[HexBytes]) ->
# Compute the word that contains the last byte
stop_word = math.ceil((offset_int + size_int) / 32)

byte_slice = b""
end_index = stop_word + 1
byte_slice = b"".join(memory[start_word:end_index])
offset_index = offset_int % 32

for word in memory[start_word:stop_word]:
byte_slice += word
# NOTE: Add 4 for the selector.

offset_index = offset_int % 32
return HexBytes(byte_slice[offset_index:size_int])
end_bytes_index = offset_index + size_int
return_bytes = byte_slice[offset_index:end_bytes_index]
return HexBytes(return_bytes)


def _create_node_from_call(
Expand All @@ -127,16 +138,16 @@ def _create_node_from_call(
raise NotImplementedError()

node = CallTreeNode(**node_kwargs)

for frame in trace:
if frame.op in ("CALL", "DELEGATECALL", "STATICCALL"):
child_node_kwargs = {}
child_node_kwargs = {
"address": frame.stack[-2][-20:],
"depth": frame.depth,
"gas_limit": int(frame.stack[-1].hex(), 16),
"gas_cost": frame.gas_cost,
}

child_node_kwargs["address"] = frame.stack[-2][-20:] # address is 20 bytes in EVM
child_node_kwargs["depth"] = frame.depth
# TODO: Validate gas values
child_node_kwargs["gas_limit"] = int(frame.stack[-1].hex(), 16)
child_node_kwargs["gas_cost"] = frame.gas_cost

if frame.op == "CALL":
child_node_kwargs["call_type"] = CallType.MUTABLE
Expand All @@ -160,10 +171,13 @@ def _create_node_from_call(

# TODO: Handle internal nodes using JUMP and JUMPI

elif frame.op in ("SELFDESTRUCT", "STOP"):
# TODO: Handle the internal value transfer in SELFDESTRUCT
elif frame.op == "SELFDESTRUCT":
# TODO: Handle the internal value transfer
node.selfdestruct = True
break

elif frame.op == "STOP":
# TODO: Handle "execution halted" vs. gas limit reached
node.selfdestruct = frame.op == "SELFDESTRUCT"
break

elif frame.op in ("RETURN", "REVERT"):
Expand All @@ -178,4 +192,5 @@ def _create_node_from_call(
# NOTE: ignore other opcodes

# TODO: Handle "execution halted" vs. gas limit reached

return node
83 changes: 83 additions & 0 deletions evm_trace/display.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from typing import TYPE_CHECKING, Iterable, Optional

from eth_utils import to_checksum_address

from evm_trace.enums import CallType

if TYPE_CHECKING:
from evm_trace.base import CallTreeNode


class DisplayableCallTreeNode(object):
_FILE_MIDDLE_PREFIX = "├──"
_FILE_LAST_PREFIX = "└──"
_PARENT_PREFIX_MIDDLE = " "
_PARENT_PREFIX_LAST = "│ "

def __init__(
self,
call: "CallTreeNode",
parent: Optional["DisplayableCallTreeNode"] = None,
is_last: bool = False,
):
self.call = call
self.parent = parent
self.is_last = is_last

@property
def depth(self) -> int:
return self.call.depth

@property
def title(self) -> str:
call_type = self.call.call_type.value.upper()
call_mnemonic = "CALL" if self.call.call_type == CallType.MUTABLE else f"{call_type}CALL"
address_hex_str = self.call.address.hex()

try:
address = to_checksum_address(address_hex_str)
except ImportError:
# Ignore checksumming if user does not have eth-hash backend installed.
address = address_hex_str

cost = self.call.gas_cost

call_path = str(address)
if self.call.calldata:
call_path = f"{call_path}.<{self.call.calldata[:4].hex()}>"

return f"{call_mnemonic}: {call_path} [{cost} gas]"

@classmethod
def make_tree(
cls,
root: "CallTreeNode",
parent: Optional["DisplayableCallTreeNode"] = None,
is_last: bool = False,
) -> Iterable["DisplayableCallTreeNode"]:
displayable_root = cls(root, parent=parent, is_last=is_last)
yield displayable_root

count = 1
for child_node in root.calls:
is_last = count == len(root.calls)
if child_node.calls:
yield from cls.make_tree(child_node, parent=displayable_root, is_last=is_last)
else:
yield cls(child_node, parent=displayable_root, is_last=is_last)

count += 1

def __str__(self) -> str:
if self.parent is None:
return self.title

_filename_prefix = self._FILE_LAST_PREFIX if self.is_last else self._FILE_MIDDLE_PREFIX

parts = [f"{_filename_prefix} {self.title}"]
parent = self.parent
while parent and parent.parent is not None:
parts.append(self._PARENT_PREFIX_MIDDLE if parent.is_last else self._PARENT_PREFIX_LAST)
parent = parent.parent

return "".join(reversed(parts))
9 changes: 9 additions & 0 deletions evm_trace/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from enum import Enum


class CallType(Enum):
INTERNAL = "INTERNAL" # Non-opcode internal call
STATIC = "STATIC" # STATICCALL opcode
MUTABLE = "MUTABLE" # CALL opcode
DELEGATE = "DELEGATE" # DELEGATECALL opcode
SELFDESTRUCT = "SELFDESTRUCT" # SELFDESTRUCT opcode
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,11 @@ write_to = "evm_trace/version.py"

[tool.black]
line-length = 100
target-version = ['py37', 'py38', 'py39']
target-version = ['py37', 'py38', 'py39', 'py310']
include = '\.pyi?$'

[tool.pytest.ini_options]
addopts = """
-n auto
-p no:ape_test
--cov-branch
--cov-report term
Expand Down
10 changes: 6 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
"pytest-xdist", # multi-process runner
"pytest-cov", # Coverage analyzer plugin
"hypothesis>=6.2.0,<7.0", # Strategy-based fuzzer
"eth-hash[pysha3]", # For eth-utils address checksumming
],
"lint": [
"black>=22.3.0,<23.0", # auto-formatter and linter
"mypy>=0.910,<1.0", # Static type analyzer
"flake8>=3.8.3,<4.0", # Style linter
"isort>=5.9.3,<6.0", # Import sorting linter
"mypy>=0.960,<1.0", # Static type analyzer
"flake8>=4.0.1,<5.0", # Style linter
"isort>=5.10.1,<6.0", # Import sorting linter
],
"release": [ # `release` GitHub Action job uses this
"setuptools", # Installation tool
Expand Down Expand Up @@ -56,7 +57,7 @@
"importlib-metadata ; python_version<'3.8'",
"pydantic>=1.9.0,<2.0",
"hexbytes>=0.2.2,<1.0.0",
"eth-utils==1.10.0",
"eth-utils>=1.10.0",
antazoey marked this conversation as resolved.
Show resolved Hide resolved
], # NOTE: Add 3rd party libraries here
python_requires=">=3.7.2,<4",
extras_require=extras_require,
Expand All @@ -77,5 +78,6 @@
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
],
)
File renamed without changes.
Loading