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

fix: issues preventing deploy transaction errors from being very useful [APE-1141] #1510

Merged
merged 9 commits into from
Jul 3, 2023
Merged
Show file tree
Hide file tree
Changes from 3 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
41 changes: 41 additions & 0 deletions docs/userguides/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,47 @@ def test_unauthorized_withdraw(contract, hacker):
contract.withdraw(sender=hacker)
```

You can also use the ABI of the error if you don't have access to an instance:
antazoey marked this conversation as resolved.
Show resolved Hide resolved

```python
import ape

def test_unauthorized(contract, hacker, project):
with ape.reverts(project.MyOtherContract.Unauthorized2, addr=hacker.address):
antazoey marked this conversation as resolved.
Show resolved Hide resolved
contract.withdraw(sender=hacker)
```

You may need to use the ABI approach for asserting on custom errors that occur during failing `deploy` transactions because you won't have access to the contract instance yet.
Here is an example of what that may look like:

```python
import ape

def test_error_on_deploy(account, project):
with ape.reverts(project.Token.MyCustomError):
ape.project.HasError.deploy(sender=account)
```

Alternatively, you can attempt to use the address from the revert error to find the error type.
**NOTE**: The address will only exist for transactions that were published (e.g. not for failures during estimating gas), and this may only work on certain providers.

```python
import ape

def test_error_on_deploy(account):
# NOTE: We are using `as rev` here to capture the revert info
# so we can attempt to lookup the contract later.
with ape.reverts() as rev:
ape.project.HasError.deploy(sender=account)

assert rev.value.address is not None, "Receipt never found, contract never cached"

# Grab the cached instance using the error's address
# and assert the custom error this way.
contract = ape.Contract(rev.value.address)
assert isinstance(rev.value, contract.MyError)
```

## Multi-chain Testing

The Ape framework supports connecting to alternative networks / providers in tests.
Expand Down
8 changes: 8 additions & 0 deletions src/ape/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import sys as _sys

from ape.managers.project import ProjectManager as Project
from ape.pytest.contextmanagers import RevertsContextManager
from ape.utils import ManagerAccessMixin as _ManagerAccessMixin

# Wiring together the application
Expand Down Expand Up @@ -42,6 +43,12 @@
convert = _ManagerAccessMixin.conversion_manager.convert
"""Conversion utility function. See :class:`ape.managers.converters.ConversionManager`."""

reverts = RevertsContextManager
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
"""
Catch and expect contract logic reverts. Resembles ``pytest.raises()``.
"""


__all__ = [
"accounts",
"chain",
Expand All @@ -52,4 +59,5 @@
"networks",
"project",
"Project", # So you can load other projects
"reverts",
]
11 changes: 3 additions & 8 deletions src/ape/api/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,22 +214,17 @@ def deploy(
Returns:
:class:`~ape.contracts.ContractInstance`: An instance of the deployed contract.
"""

from ape.contracts import ContractInstance

txn = contract(*args, **kwargs)
txn.sender = self.address
receipt = self.call(txn, **kwargs)

address = receipt.contract_address
if not address:
receipt = contract._cache_wrap(lambda: self.call(txn, **kwargs))
if not (address := receipt.contract_address):
raise AccountsError(f"'{receipt.txn_hash}' did not create a contract.")

contract_type = contract.contract_type
styled_address = click.style(receipt.contract_address, bold=True)
contract_name = contract_type.name or "<Unnamed Contract>"
logger.success(f"Contract '{contract_name}' deployed to: {styled_address}")
instance = ContractInstance.from_receipt(receipt, contract_type)
instance = self.chain_manager.contracts.instance_from_receipt(receipt, contract_type)
self.chain_manager.contracts.cache_deployment(instance)

if publish:
Expand Down
10 changes: 4 additions & 6 deletions src/ape/api/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1252,13 +1252,11 @@ def send_transaction(self, txn: TransactionAPI) -> ReceiptAPI:
if txn.signature or not txn.sender:
txn_hash = self.web3.eth.send_raw_transaction(txn.serialize_transaction())
else:
if (
txn.sender not in self.chain_manager.provider.web3.eth.accounts # type: ignore # noqa
):
if txn.sender not in self.web3.eth.accounts:
self.chain_manager.provider.unlock_account(txn.sender)
txn_dict = txn.dict()
txn_params = cast(TxParams, txn_dict)
txn_hash = self.web3.eth.send_transaction(txn_params)

txn_hash = self.web3.eth.send_transaction(cast(TxParams, txn.dict()))

except (ValueError, Web3ContractLogicError) as err:
vm_err = self.get_virtual_machine_error(err, txn=txn)
raise vm_err from err
Expand Down
79 changes: 72 additions & 7 deletions src/ape/contracts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
from functools import partial
from itertools import islice
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Tuple, Type, Union
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Type, Union

import click
import pandas as pd
from ethpm_types import ContractType, HexBytes
from ethpm_types.abi import ConstructorABI, ErrorABI, EventABI, MethodABI
from ethpm_types.abi import ABI, ConstructorABI, ErrorABI, EventABI, MethodABI

from ape.api import AccountAPI, Address, ReceiptAPI, TransactionAPI
from ape.api.address import BaseAddress
Expand All @@ -17,6 +17,7 @@
ArgumentsLengthError,
ChainError,
ContractError,
ContractLogicError,
CustomError,
TransactionNotFoundError,
)
Expand Down Expand Up @@ -1180,6 +1181,43 @@ def __init__(self, contract_type: ContractType) -> None:
def __repr__(self) -> str:
return f"<{self.contract_type.name}>"

def __getattr__(self, name: str) -> ABI:
"""
Access an ABI via its name using ``.`` access.
**WARN**: If multiple ABIs have the same name, you may need
to get the ABI a different way, such as using ``.contract_type``.

Args:
name (str): The name

Returns:

"""

try:
# First, check if requesting a regular attribute on this class.
return self.__getattribute__(name)
except AttributeError:
pass

try:
if name in self.contract_type.methods:
return self.contract_type.methods[name]

elif name in self.contract_type.events:
return self.contract_type.events[name]

elif name in self.contract_type.errors:
return self.contract_type.errors[name]

# TODO: Add self.contract_type.structs

except Exception as err:
# __getattr__ must raise AttributeError
raise ApeAttributeError(str(err)) from err

raise ApeAttributeError(f"No ABI with name '{name}'.")

@property
def deployments(self):
"""
Expand Down Expand Up @@ -1244,14 +1282,16 @@ def deploy(self, *args, publish: bool = False, **kwargs) -> ContractInstance:

if "sender" in kwargs and isinstance(kwargs["sender"], AccountAPI):
# Handle account-related preparation if needed, such as signing
receipt = kwargs["sender"].call(txn, **kwargs)
receipt = self._cache_wrap(lambda: kwargs["sender"].call(txn, **kwargs))

else:
txn = self.provider.prepare_transaction(txn)
receipt = (
self.provider.send_private_transaction(txn)
if private
else self.provider.send_transaction(txn)
receipt = self._cache_wrap(
lambda: (
self.provider.send_private_transaction(txn)
if private
else self.provider.send_transaction(txn)
)
)

address = receipt.contract_address
Expand All @@ -1270,6 +1310,31 @@ def deploy(self, *args, publish: bool = False, **kwargs) -> ContractInstance:

return instance

def _cache_wrap(self, function: Callable) -> ReceiptAPI:
"""
A helper method to ensure a contract type is cached as early on
as possible to help enrich errors from ``deploy()`` transactions
as well produce nicer tracebacks for these errors. It also helps
make assertions about these revert conditions in your tests.
"""
try:
return function()
except ContractLogicError as err:
if address := err.address:
self.chain_manager.contracts[address] = self.contract_type
err._set_tb() # Re-try setting source traceback
new_err = None
try:
# Try enrichment again now that the contract type is cached.
new_err = self.compiler_manager.enrich_error(err)
except Exception:
pass

if new_err:
raise new_err from err

raise # The error after caching.

def declare(self, *args, **kwargs) -> ReceiptAPI:
transaction = self.provider.network.ecosystem.encode_contract_blueprint(
self.contract_type, *args, **kwargs
Expand Down
36 changes: 23 additions & 13 deletions src/ape/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,15 +123,25 @@ def __init__(

# Finalizes expected revert message.
super().__init__(ex_message)
self._set_tb()

if not source_traceback and txn:
self.source_traceback = _get_ape_traceback(txn)
@property
def address(self) -> Optional["AddressType"]:
return (
self.contract_address
or getattr(self.txn, "receiver", None)
or getattr(self.txn, "contract_address", None)
)

def _set_tb(self):
if not self.source_traceback and self.txn:
self.source_traceback = _get_ape_traceback(self.txn)

src_tb = self.source_traceback
if src_tb is not None and txn is not None:
if src_tb is not None and self.txn is not None:
# Create a custom Pythonic traceback using lines from the sources
# found from analyzing the trace of the transaction.
py_tb = _get_custom_python_traceback(self, txn, src_tb)
py_tb = _get_custom_python_traceback(self, self.txn, src_tb)
if py_tb:
self.__traceback__ = py_tb

Expand Down Expand Up @@ -193,16 +203,16 @@ def dev_message(self) -> Optional[str]:
if len(trace) == 0:
raise ValueError("Missing trace.")

contract_address = self.contract_address or getattr(self.txn, "receiver", None)
if not contract_address:
raise ValueError("Could not fetch contract information to check dev message.")
if address := self.address:
try:
contract_type = trace[-1].chain_manager.contracts[address]
except Exception as err:
raise ValueError(
f"Could not fetch contract at {address} to check dev message."
) from err

try:
contract_type = trace[-1].chain_manager.contracts[contract_address]
except ValueError as err:
raise ValueError(
f"Could not fetch contract at {contract_address} to check dev message."
) from err
else:
raise ValueError("Could not fetch contract information to check dev message.")

if contract_type.pcmap is None:
raise ValueError("Compiler does not support source code mapping.")
Expand Down
15 changes: 15 additions & 0 deletions src/ape/managers/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,21 @@ def instance_at(

return ContractInstance(contract_address, contract_type, txn_hash=txn_hash)

def instance_from_receipt(
self, receipt: ReceiptAPI, contract_type: ContractType
) -> ContractInstance:
"""
A convenience method for creating instances from receipts.

Args:
receipt (:class:`~ape.api.transactions.ReceiptAPI`): The receipt.

Returns:
:class:`~ape.contracts.base.ContractInstance`
"""
# NOTE: Mostly just needed this method to avoid a local import.
return ContractInstance.from_receipt(receipt, contract_type)

def get_deployments(self, contract_container: ContractContainer) -> List[ContractInstance]:
"""
Retrieves previous deployments of a contract container or contract type.
Expand Down
10 changes: 5 additions & 5 deletions src/ape/managers/compilers.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,10 @@ def __getattr__(self, name: str) -> Any:
except AttributeError:
pass

compiler = self.get_compiler(name)
if not compiler:
raise ApeAttributeError(f"No attribute or compiler named '{name}'.")
if compiler := self.get_compiler(name):
return compiler

return compiler
raise ApeAttributeError(f"No attribute or compiler named '{name}'.")

@property
def registered_compilers(self) -> Dict[str, CompilerAPI]:
Expand Down Expand Up @@ -235,7 +234,7 @@ def enrich_error(self, err: ContractLogicError) -> ContractLogicError:
:class:`~ape.exceptions.ContractLogicError`: The enriched exception.
"""

address = err.contract_address or getattr(err.txn, "receiver", None)
address = err.address
if not address:
# Contract address not found.
return err
Expand All @@ -244,6 +243,7 @@ def enrich_error(self, err: ContractLogicError) -> ContractLogicError:
contract = self.chain_manager.contracts.get(address)
except RecursionError:
contract = None

if not contract or not contract.source_id:
# Contract or source not found.
return err
Expand Down
Loading