Skip to content

Commit

Permalink
fix: issue when contracts w/o source IDs would not enrich (ApeWorX#2047)
Browse files Browse the repository at this point in the history
  • Loading branch information
antazoey authored May 3, 2024
1 parent 2d03eca commit aec6abb
Show file tree
Hide file tree
Showing 11 changed files with 247 additions and 35 deletions.
22 changes: 22 additions & 0 deletions src/ape/api/networks.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from ethpm_types.abi import ABIType, ConstructorABI, EventABI, MethodABI

from ape.exceptions import (
CustomError,
NetworkError,
NetworkMismatchError,
NetworkNotFoundError,
Expand Down Expand Up @@ -610,6 +611,27 @@ def get_python_types( # type: ignore[empty-body]
Union[Type, Sequence]: The Python types for the given ABI type.
"""

@raises_not_implemented
def decode_custom_error(
self,
data: HexBytes,
address: AddressType,
**kwargs,
) -> Optional[CustomError]:
"""
Decode a custom error class from an ABI defined in a contract.
Args:
data (HexBytes): The error data contining the selector
and input data.
address (AddressType): The address of the contract containing
the error.
**kwargs: Additional init kwargs for the custom error class.
Returns:
Optional[CustomError]: If it able to decode one, else ``None``.
"""


class ProviderContextManager(ManagerAccessMixin):
"""
Expand Down
2 changes: 1 addition & 1 deletion src/ape/api/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ def unlock_account(self, address: AddressType) -> bool: # type: ignore[empty-bo

@raises_not_implemented
def get_transaction_trace( # type: ignore[empty-body]
self, txn_hash: str
self, txn_hash: Union[HexBytes, str]
) -> Iterator[TraceFrame]:
"""
Provide a detailed description of opcodes.
Expand Down
15 changes: 10 additions & 5 deletions src/ape/contracts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import click
import pandas as pd
from eth_pydantic_types import HexBytes
from eth_utils import to_hex
from ethpm_types.abi import ConstructorABI, ErrorABI, EventABI, MethodABI
from ethpm_types.contract_type import ABI_W_SELECTOR_T, ContractType

Expand Down Expand Up @@ -836,12 +837,14 @@ def __init__(
self,
address: AddressType,
contract_type: ContractType,
txn_hash: Optional[str] = None,
txn_hash: Optional[Union[str, HexBytes]] = None,
) -> None:
super().__init__()
self._address = address
self.contract_type = contract_type
self.txn_hash = txn_hash
self.txn_hash = (
(txn_hash if isinstance(txn_hash, str) else to_hex(txn_hash)) if txn_hash else None
)
self._cached_receipt: Optional[ReceiptAPI] = None

def __call__(self, *args, **kwargs) -> ReceiptAPI:
Expand Down Expand Up @@ -1319,7 +1322,9 @@ def deployments(self):

return self.chain_manager.contracts.get_deployments(self)

def at(self, address: AddressType, txn_hash: Optional[str] = None) -> ContractInstance:
def at(
self, address: AddressType, txn_hash: Optional[Union[str, HexBytes]] = None
) -> ContractInstance:
"""
Get a contract at the given address.
Expand All @@ -1334,8 +1339,8 @@ def at(self, address: AddressType, txn_hash: Optional[str] = None) -> ContractIn
**NOTE**: Things will not work as expected if the contract is not actually
deployed to this address or if the contract at the given address has
a different ABI than :attr:`~ape.contracts.ContractContainer.contract_type`.
txn_hash (str): The hash of the transaction that deployed the contract, if
available. Defaults to ``None``.
txn_hash (Union[str, HexBytes]): The hash of the transaction that deployed the
contract, if available. Defaults to ``None``.
Returns:
:class:`~ape.contracts.ContractInstance`
Expand Down
20 changes: 20 additions & 0 deletions src/ape/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import tempfile
import time
import traceback
from functools import cached_property
from inspect import getframeinfo, stack
from pathlib import Path
from types import CodeType, TracebackType
Expand Down Expand Up @@ -209,6 +210,20 @@ def address(self) -> Optional["AddressType"]:
or getattr(self.txn, "contract_address", None)
)

@cached_property
def contract_type(self) -> Optional[ContractType]:
if not (address := self.address):
# Contract address not found.
return None

# Lazy import because of exceptions.py root nature.
from ape.utils.basemodel import ManagerAccessMixin

try:
return ManagerAccessMixin.chain_manager.contracts.get(address)
except (RecursionError, ProviderNotConnectedError):
return None

def _set_tb(self):
if not self.source_traceback and self.txn:
self.source_traceback = _get_ape_traceback_from_tx(self.txn)
Expand Down Expand Up @@ -756,6 +771,11 @@ def name(self) -> str:
"""
return self.abi.name

def __repr__(self) -> str:
name = self.__class__.__name__ # Custom error name
calldata = ", ".join(sorted([f"{k}={v}" for k, v in self.inputs.items()])) or ""
return f"{name}({calldata})"


def _get_ape_traceback_from_tx(txn: FailedTxn) -> Optional["SourceTraceback"]:
from ape.api.transactions import ReceiptAPI
Expand Down
7 changes: 4 additions & 3 deletions src/ape/managers/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import IO, Collection, Dict, Iterator, List, Optional, Set, Type, Union, cast

import pandas as pd
from eth_pydantic_types import HexBytes
from ethpm_types import ABI, ContractType
from rich import get_console
from rich.console import Console as RichConsole
Expand Down Expand Up @@ -1074,7 +1075,7 @@ def instance_at(
self,
address: Union[str, AddressType],
contract_type: Optional[ContractType] = None,
txn_hash: Optional[str] = None,
txn_hash: Optional[Union[str, HexBytes]] = None,
abi: Optional[Union[List[ABI], Dict, str, Path]] = None,
) -> ContractInstance:
"""
Expand All @@ -1093,8 +1094,8 @@ def instance_at(
plugin, you can also provide an ENS domain name.
contract_type (Optional[``ContractType``]): Optionally provide the contract type
in case it is not already known.
txn_hash (Optional[str]): The hash of the transaction responsible for deploying the
contract, if known. Useful for publishing. Defaults to ``None``.
txn_hash (Optional[Union[str, HexBytes]]): The hash of the transaction responsible for
deploying the contract, if known. Useful for publishing. Defaults to ``None``.
abi (Optional[Union[List[ABI], Dict, str, Path]]): Use an ABI str, dict, path,
or ethpm models to create a contract instance class.
Expand Down
71 changes: 53 additions & 18 deletions src/ape/managers/compilers.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Sequence, Set, Union

from eth_pydantic_types import HexBytes
from ethpm_types import ContractType
from ethpm_types.source import Content

from ape.api import CompilerAPI
from ape.contracts import ContractContainer
from ape.exceptions import CompilerError, ContractLogicError
from ape.exceptions import CompilerError, ContractLogicError, CustomError
from ape.logging import logger
from ape.managers.base import BaseManager
from ape.utils import log_instead_of_fail
Expand Down Expand Up @@ -321,28 +322,62 @@ def enrich_error(self, err: ContractLogicError) -> ContractLogicError:
Returns:
:class:`~ape.exceptions.ContractLogicError`: The enriched exception.
"""

address = err.address
if not address:
# Contract address not found.
# First, try enriching using their ABI.
err = self.get_custom_error(err) or err
if not (contract_type := err.contract_type):
return err

try:
contract = self.chain_manager.contracts.get(address)
except RecursionError:
contract = None
# Delegate to compiler APIs.
elif source_id := contract_type.source_id:
# Source ID found! Delegate to a CompilerAPI for enrichment.
ext = get_full_extension(Path(source_id))
if ext not in self.registered_compilers:
# Compiler not found.
return err

if not contract or not contract.source_id:
# Contract or source not found.
return err
compiler = self.registered_compilers[ext]
return compiler.enrich_error(err)

ext = get_full_extension(Path(contract.source_id))
if ext not in self.registered_compilers:
# Compiler not found.
return err
# No further enrichment.
return err

compiler = self.registered_compilers[ext]
return compiler.enrich_error(err)
def get_custom_error(self, err: ContractLogicError) -> Optional[CustomError]:
"""
Get a custom error for the given contract logic error using the contract-type
found from address-data in the error. Returns ``None`` if the given error is
not a custom-error or it is not able to find the associated contract type or
address.
Args:
err (:class:`~ape.exceptions.ContractLogicError`): The error to enrich
as a custom error.
Returns:
"""
message = err.revert_message
if not message.startswith("0x"):
return None
elif not (address := err.address):
return None

if provider := self.network_manager.active_provider:
ecosystem = provider.network.ecosystem
else:
# Default to Ethereum.
ecosystem = self.network_manager.ethereum

try:
return ecosystem.decode_custom_error(
HexBytes(message),
address,
base_err=err.base_err,
source_traceback=err.source_traceback,
trace=err.trace,
txn=err.txn,
)
except NotImplementedError:
return None

def flatten_contract(self, path: Path, **kwargs) -> Content:
"""
Expand Down
2 changes: 1 addition & 1 deletion src/ape/types/signatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def _left_pad_bytes(val: bytes, num_bytes: int) -> bytes:
class _Signature:
v: int
"""
The version byte (``v``) in an Ethereum-style ECDSA signture.
The version byte (``v``) in an Ethereum-style ECDSA signature.
"""

r: bytes
Expand Down
61 changes: 60 additions & 1 deletion src/ape_ethereum/ecosystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@
from ape.api import BlockAPI, EcosystemAPI, PluginConfig, ReceiptAPI, TransactionAPI
from ape.api.networks import LOCAL_NETWORK_NAME
from ape.contracts.base import ContractCall
from ape.exceptions import ApeException, APINotImplementedError, ConversionError, DecodingError
from ape.exceptions import (
ApeException,
APINotImplementedError,
ConversionError,
CustomError,
DecodingError,
)
from ape.managers.config import merge_configs
from ape.types import (
AddressType,
Expand Down Expand Up @@ -1159,6 +1165,59 @@ def _enrich_returndata(
def get_python_types(self, abi_type: ABIType) -> Union[Type, Sequence]:
return self._python_type_for_abi_type(abi_type)

def decode_custom_error(
self,
data: HexBytes,
address: AddressType,
**kwargs,
) -> Optional[CustomError]:
# Use an instance (required for proper error caching).
contract = self.chain_manager.contracts.instance_at(address)

selector = data[:4]
input_data = data[4:]

abi = None
if selector not in contract.contract_type.errors:
# ABI not found. Try looking at the "last" contract.
if not (tx := kwargs.get("txn")) or not self.network_manager.active_provider:
return None
elif not (last_addr := self._get_last_address_from_trace(tx.txn_hash)):
return None

if last_addr == address:
# Avoid checking same address twice.
return None

try:
if not (cerr := self.decode_custom_error(data, last_addr)):
return cerr
except NotImplementedError:
return None

# error never found.
return None

abi = contract.contract_type.errors[selector]
error_cls = contract.get_error_by_signature(abi.signature)
inputs = self.decode_calldata(abi, input_data)
kwargs["contract_address"] = address
return error_cls(abi, inputs, **kwargs)

def _get_last_address_from_trace(self, txn_hash: Union[str, HexBytes]) -> Optional[AddressType]:
try:
trace = list(self.chain_manager.provider.get_transaction_trace(txn_hash))
except Exception:
return None

for frame in trace[::-1]:
if not (addr := frame.contract_address):
continue

return addr

return None


def parse_type(type_: Dict[str, Any]) -> Union[str, Tuple, List]:
if "tuple" not in type_["type"]:
Expand Down
11 changes: 9 additions & 2 deletions src/ape_ethereum/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -1355,9 +1355,16 @@ def disconnect(self):
self._web3 = None
self._client_version = None

def get_transaction_trace(self, txn_hash: str) -> Iterator[TraceFrame]:
def get_transaction_trace(self, txn_hash: Union[HexBytes, str]) -> Iterator[TraceFrame]:
if isinstance(txn_hash, HexBytes):
txn_hash_str = str(to_hex(txn_hash))
else:
txn_hash_str = txn_hash

frames = self._stream_request(
"debug_traceTransaction", [txn_hash, {"enableMemory": True}], "result.structLogs.item"
"debug_traceTransaction",
[txn_hash_str, {"enableMemory": True}],
"result.structLogs.item",
)
for frame in create_trace_frames(frames):
yield self._create_trace_frame(frame)
Expand Down
20 changes: 17 additions & 3 deletions src/ape_ethereum/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,17 +276,31 @@ def show_trace(self, verbose: bool = False, file: IO[str] = sys.stdout):
if call_tree.failed:
default_message = "reverted without message"
returndata = HexBytes(call_tree.raw["returndata"])
if not to_hex(returndata).startswith(
if to_hex(returndata).startswith(
"0x08c379a00000000000000000000000000000000000000000000000000000000000000020"
):
revert_message = default_message
else:
# Extra revert-message
decoded_result = decode(("string",), returndata[4:])
if len(decoded_result) == 1:
revert_message = f'reverted with message: "{decoded_result[0]}"'
else:
revert_message = default_message

elif address := (self.receiver or self.contract_address):
# Try to enrich revert error using ABI.
if provider := self.network_manager.active_provider:
ecosystem = provider.network.ecosystem
else:
# Default to Ethereum.
ecosystem = self.network_manager.ethereum

try:
instance = ecosystem.decode_custom_error(returndata, address)
except NotImplementedError:
pass
else:
revert_message = repr(instance)

self.chain_manager._reports.show_trace(
call_tree,
sender=self.sender,
Expand Down
Loading

0 comments on commit aec6abb

Please sign in to comment.