Skip to content

Commit 46f0d8e

Browse files
authored
feat: show decoded calldata on transaction signing (#2655)
1 parent 691f581 commit 46f0d8e

File tree

12 files changed

+335
-155
lines changed

12 files changed

+335
-155
lines changed

docs/userguides/testing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ assert x != 0 # dev: invalid value
449449
Take for example:
450450

451451
```python
452-
# @version 0.4.0
452+
# @version 0.4.3
453453

454454
@external
455455
def check_value(_value: uint256) -> bool:

src/ape/api/transactions.py

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import IO, TYPE_CHECKING, Any, NoReturn, Optional, Union
88

99
from eth_pydantic_types import HexBytes, HexStr
10-
from eth_utils import is_hex, to_hex, to_int
10+
from eth_utils import humanize_hexstr, is_hex, to_hex, to_int
1111
from pydantic import ConfigDict, field_validator
1212
from pydantic.fields import Field
1313
from tqdm import tqdm # type: ignore
@@ -26,6 +26,7 @@
2626
from ape.types.signatures import TransactionSignature
2727
from ape.utils.basemodel import BaseInterfaceModel, ExtraAttributesMixin, ExtraModelAttributes
2828
from ape.utils.misc import log_instead_of_fail, raises_not_implemented
29+
from ape.utils.trace import prettify_function
2930

3031
if TYPE_CHECKING:
3132
from ethpm_types.abi import EventABI, MethodABI
@@ -169,6 +170,10 @@ def trace(self) -> "TraceAPI":
169170
"""
170171
return self.provider.get_transaction_trace(to_hex(self.txn_hash))
171172

173+
@cached_property
174+
def _calldata_repr(self) -> "CalldataRepr":
175+
return self.local_project.config.display.calldata
176+
172177
@abstractmethod
173178
def serialize_transaction(self) -> bytes:
174179
"""
@@ -199,22 +204,63 @@ def to_string(self, calldata_repr: Optional["CalldataRepr"] = None) -> str:
199204
"""
200205
data = self.model_dump(mode="json") # JSON mode used for style purposes.
201206

202-
if calldata_repr is None:
203-
# If was not specified, use the default value from the config.
204-
calldata_repr = self.local_project.config.display.calldata
207+
calldata_repr = calldata_repr or self._calldata_repr
208+
data["data"] = self._get_calldata_repr_str(calldata_repr=calldata_repr)
209+
210+
params = "\n ".join(f"{k}: {v}" for k, v in data.items())
211+
cls_name = getattr(type(self), "__name__", TransactionAPI.__name__)
212+
tx_str = f"{cls_name}:\n {params}"
213+
214+
# Decode the actual call so the user can see the function.
215+
if decoded := self._decoded_call():
216+
tx_str = f"{tx_str}\n\n\t{decoded}"
217+
218+
return tx_str
219+
220+
def _get_calldata_repr_str(self, calldata_repr: "CalldataRepr") -> str:
221+
calldata = HexBytes(self.data)
205222

206223
# Elide the transaction calldata for abridged representations if the length exceeds 8
207224
# (4 bytes for function selector and trailing 4 bytes).
208-
calldata = HexBytes(data["data"])
209-
data["data"] = (
225+
return (
210226
calldata[:4].to_0x_hex() + "..." + calldata[-4:].hex()
211227
if calldata_repr == "abridged" and len(calldata) > 8
212228
else calldata.to_0x_hex()
213229
)
214230

215-
params = "\n ".join(f"{k}: {v}" for k, v in data.items())
216-
cls_name = getattr(type(self), "__name__", TransactionAPI.__name__)
217-
return f"{cls_name}:\n {params}"
231+
def _decoded_call(self) -> Optional[str]:
232+
if not self.receiver:
233+
return "constructor()"
234+
235+
if not (contract_type := self.chain_manager.contracts.get(self.receiver)):
236+
# Unknown.
237+
return None
238+
239+
try:
240+
abi = contract_type.methods[HexBytes(self.data)[:4]]
241+
except KeyError:
242+
return None
243+
244+
ecosystem = (
245+
self.provider.network.ecosystem
246+
if self.network_manager.active_provider
247+
else self.network_manager.ethereum
248+
)
249+
decoded_calldata = ecosystem.decode_calldata(abi, HexBytes(self.data)[4:])
250+
251+
# NOTE: There is no actual returndata yet, but we can show the type.
252+
return_types = [t.canonical_type for t in abi.outputs]
253+
if len(return_types) == 1:
254+
return_types = return_types[0]
255+
256+
return prettify_function(
257+
abi.name or "",
258+
decoded_calldata,
259+
returndata=return_types,
260+
contract=contract_type.name or humanize_hexstr(self.receiver),
261+
is_create=self.receiver is None,
262+
depth=4,
263+
)
218264

219265

220266
class ConfirmationsProgressBar:

src/ape/utils/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,16 @@ def __getattr__(name: str):
108108

109109
return getattr(testing_module, name)
110110

111-
elif name in ("USER_ASSERT_TAG", "TraceStyles", "parse_coverage_tables", "parse_gas_table"):
111+
elif name in (
112+
"USER_ASSERT_TAG",
113+
"TraceStyles",
114+
"parse_coverage_tables",
115+
"parse_gas_table",
116+
"prettify_dict",
117+
"prettify_function",
118+
"prettify_inputs",
119+
"prettify_list",
120+
):
112121
import ape.utils.trace as trace_module
113122

114123
return getattr(trace_module, name)
@@ -177,6 +186,10 @@ def __getattr__(name: str):
177186
"parse_coverage_tables",
178187
"parse_gas_table",
179188
"path_match",
189+
"prettify_dict",
190+
"prettify_function",
191+
"prettify_inputs",
192+
"prettify_list",
180193
"pragma_str_to_specifier_set",
181194
"raises_not_implemented",
182195
"request_with_retry",

src/ape/utils/os.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def create_tempdir(name: Optional[str] = None) -> Iterator[Path]:
200200
Returns:
201201
Iterator[Path]: Context managing the temporary directory.
202202
"""
203-
with TemporaryDirectory() as temp_dir:
203+
with TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
204204
temp_path = Path(temp_dir).resolve()
205205

206206
if name:

src/ape/utils/trace.py

Lines changed: 205 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from collections.abc import Sequence
22
from fnmatch import fnmatch
33
from statistics import mean, median
4-
from typing import TYPE_CHECKING
4+
from typing import TYPE_CHECKING, Any, Optional, Union
55

6+
from eth_utils import is_0x_prefixed, to_hex
67
from rich.box import SIMPLE
78
from rich.table import Table
89

@@ -13,6 +14,209 @@
1314
from ape.types.trace import ContractFunctionPath, GasReport
1415

1516
USER_ASSERT_TAG = "USER_ASSERT"
17+
DEFAULT_WRAP_THRESHOLD = 50
18+
19+
20+
def prettify_function(
21+
method: str,
22+
calldata: Any,
23+
contract: Optional[str] = None,
24+
returndata: Optional[Any] = None,
25+
stylize: bool = False,
26+
is_create: bool = False,
27+
depth: int = 0,
28+
) -> str:
29+
"""
30+
Prettify the given method call-string to a displayable, prettier string.
31+
Useful for displaying traces and decoded calls.
32+
33+
Args:
34+
method (str): the method call-string to prettify.
35+
calldata (Any): Arguments to the method.
36+
contract (str | None): The contract name called.
37+
returndata (Any): Returned values from the method.
38+
stylize (bool): ``True`` to use rich styling.
39+
is_create (bool): Set to ``True`` if creating a contract for better styling.
40+
depth (int): The depth in the trace (or output) this function gets displayed.
41+
42+
Returns:
43+
str
44+
"""
45+
if "(" in method:
46+
# Only show short name, not ID name
47+
# (it is the full signature when multiple methods have the same name).
48+
method = method.split("(")[0].strip() or method
49+
50+
if stylize:
51+
method = f"[{TraceStyles.METHODS}]{method}[/]"
52+
if contract:
53+
contract = f"[{TraceStyles.CONTRACTS}]{contract}[/]"
54+
55+
arguments_str = prettify_inputs(calldata, stylize=stylize)
56+
if is_create and is_0x_prefixed(arguments_str):
57+
# Un-enriched CREATE calldata is a massive hex.
58+
arguments_str = "()"
59+
60+
signature = f"{method}{arguments_str}"
61+
if not is_create and returndata not in ((), [], None, {}, ""):
62+
if return_str := _get_outputs_str(returndata, stylize=stylize, depth=depth):
63+
signature = f"{signature} -> {return_str}"
64+
65+
if contract:
66+
signature = f"{contract}.{signature}"
67+
68+
return signature
69+
70+
71+
def prettify_inputs(inputs: Any, stylize: bool = False) -> str:
72+
"""
73+
Prettify the inputs to a function or event (or alike).
74+
75+
Args:
76+
inputs (Any): the inputs to prettify.
77+
stylize (bool): ``True`` to use rich styling.
78+
79+
Returns:
80+
str
81+
"""
82+
color = TraceStyles.INPUTS if stylize else None
83+
if inputs in ["0x", None, (), [], {}]:
84+
return "()"
85+
86+
elif isinstance(inputs, dict):
87+
return prettify_dict(inputs, color=color)
88+
89+
elif isinstance(inputs, bytes):
90+
return to_hex(inputs)
91+
92+
return f"({inputs})"
93+
94+
95+
def _get_outputs_str(outputs: Any, stylize: bool = False, depth: int = 0) -> Optional[str]:
96+
if outputs in ["0x", None, (), [], {}]:
97+
return None
98+
99+
elif isinstance(outputs, dict):
100+
color = TraceStyles.OUTPUTS if stylize else None
101+
return prettify_dict(outputs, color=color)
102+
103+
elif isinstance(outputs, (list, tuple)):
104+
return (
105+
f"[{TraceStyles.OUTPUTS}]{prettify_list(outputs)}[/]"
106+
if stylize
107+
else prettify_list(outputs, depth=depth)
108+
)
109+
110+
return f"[{TraceStyles.OUTPUTS}]{outputs}[/]" if stylize else str(outputs)
111+
112+
113+
def prettify_list(
114+
ls: Union[list, tuple],
115+
depth: int = 0,
116+
indent: int = 2,
117+
wrap_threshold: int = DEFAULT_WRAP_THRESHOLD,
118+
) -> str:
119+
"""
120+
Prettify a list of values for displaying.
121+
122+
Args:
123+
ls (list): the list to prettify.
124+
depth (int): The depth the list appears in a tree structure (for traces).
125+
126+
Returns:
127+
str
128+
"""
129+
if not isinstance(ls, (list, tuple)) or len(str(ls)) < wrap_threshold:
130+
return str(ls)
131+
132+
elif ls and isinstance(ls[0], (list, tuple)):
133+
# List of lists
134+
sub_lists = [prettify_list(i) for i in ls]
135+
136+
# Use multi-line if exceeds threshold OR any of the sub-lists use multi-line
137+
extra_chars_len = (len(sub_lists) - 1) * 2
138+
use_multiline = len(str(sub_lists)) + extra_chars_len > wrap_threshold or any(
139+
["\n" in ls for ls in sub_lists]
140+
)
141+
142+
if not use_multiline:
143+
# Happens for lists like '[[0], [1]]' that are short.
144+
return f"[{', '.join(sub_lists)}]"
145+
146+
value = "[\n"
147+
num_sub_lists = len(sub_lists)
148+
index = 0
149+
spacing = indent * " " * 2
150+
for formatted_list in sub_lists:
151+
if "\n" in formatted_list:
152+
# Multi-line sub list. Append 1 more spacing to each line.
153+
indented_item = f"\n{spacing}".join(formatted_list.splitlines())
154+
value = f"{value}{spacing}{indented_item}"
155+
else:
156+
# Single line sub-list
157+
value = f"{value}{spacing}{formatted_list}"
158+
159+
if index < num_sub_lists - 1:
160+
value = f"{value},"
161+
162+
value = f"{value}\n"
163+
index += 1
164+
165+
value = f"{value}]"
166+
return value
167+
168+
return _list_to_multiline_str(ls, depth=depth)
169+
170+
171+
def prettify_dict(
172+
dictionary: dict,
173+
color: Optional[str] = None,
174+
indent: int = 2,
175+
wrap_threshold: int = DEFAULT_WRAP_THRESHOLD,
176+
) -> str:
177+
"""
178+
Prettify a dictionary.
179+
180+
Args:
181+
dictionary (dict): The dictionary to prettify.
182+
color (Optional[str]): The color to use for pretty printing.
183+
184+
Returns:
185+
str
186+
"""
187+
length = sum(len(str(v)) for v in [*dictionary.keys(), *dictionary.values()])
188+
do_wrap = length > wrap_threshold
189+
190+
index = 0
191+
end_index = len(dictionary) - 1
192+
kv_str = "(\n" if do_wrap else "("
193+
194+
for key, value in dictionary.items():
195+
if do_wrap:
196+
kv_str += indent * " "
197+
198+
if isinstance(value, (list, tuple)):
199+
value = prettify_list(value, 1 if do_wrap else 0)
200+
201+
value_str = f"[{color}]{value}[/]" if color is not None else str(value)
202+
kv_str += f"{key}={value_str}" if key and not key.isnumeric() else value_str
203+
if index < end_index:
204+
kv_str += ", "
205+
206+
if do_wrap:
207+
kv_str += "\n"
208+
209+
index += 1
210+
211+
return f"{kv_str})"
212+
213+
214+
def _list_to_multiline_str(value: Union[list, tuple], depth: int = 0, indent: int = 2) -> str:
215+
spacing = indent * " "
216+
ls_spacing = spacing * (depth + 1)
217+
joined = ",\n".join([f"{ls_spacing}{v}" for v in value])
218+
new_val = f"[\n{joined}\n{spacing * depth}]"
219+
return new_val
16220

17221

18222
class TraceStyles:

src/ape_accounts/accounts.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,7 @@ def sign_message(self, msg: Any, **signer_options) -> Optional[MessageSignature]
221221
def sign_transaction(
222222
self, txn: "TransactionAPI", **signer_options
223223
) -> Optional["TransactionAPI"]:
224-
user_approves = self.__autosign or click.confirm(f"{txn}\n\nSign: ")
225-
if not user_approves:
224+
if not (self.__autosign or click.confirm(f"{txn}\n\nSign: ")):
226225
return None
227226

228227
signature = EthAccount.sign_transaction(

0 commit comments

Comments
 (0)