Skip to content

Commit

Permalink
feat: raise_on_revert=False kwarg for allowing failures on calls an…
Browse files Browse the repository at this point in the history
…d transactions (#2181)
  • Loading branch information
antazoey committed Jul 24, 2024
1 parent ffd9ef2 commit 489e059
Show file tree
Hide file tree
Showing 12 changed files with 329 additions and 74 deletions.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
userguides/transactions
userguides/console
userguides/contracts
userguides/reverts
userguides/proxy
userguides/testing
userguides/scripts
Expand Down
4 changes: 4 additions & 0 deletions docs/userguides/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ If you need the `return_value` from a transaction, you have to either treat tran
assert receipt.return_value == 123
```

Transactions may also fail, known as a "revert".
When a transaction reverts, Ape (by default) raises a subclass of `TransactionError`, which is a Python exception.
To learn more reverts, see the [reverts guide](./reverts.html).

For more general information on transactions in the Ape framework, see [this guide](./transactions.html).

### Calls
Expand Down
137 changes: 137 additions & 0 deletions docs/userguides/reverts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Reverts

Reverts occur when a transaction or call fails for any reason.
In the case of EVM networks, reverts result in funds being returned to the sender (besides network-fees) and contract state changes unwinding.
Typically, in smart-contracts, user-defined reverts occur from `assert` statements in Vyper and `require` statements in Solidity.

Here is a Vyper example of an `assert` statement:

```python
assert msg.sender == self.owner, "!authorized"
```

The string `"!authorized"` after the assertion is the revert-message that gets forwarded to the user.

In solidity, a `require` statement looks like:

```solidity
require(msg.sender == owner, "!authorized");
```

In Ape, reverts automatically become Python exceptions.
When [interacting with a contract](./contracts.html#contract-interaction) and encountering a revert, your program will crash and you will see a stacktrace showing you where the revert occurred.
For example, assume you have contract instance variable `contract` with a Vyper method called `setNumber()`, and it reverts when the user is not the owner of the contract.
Calling it may look like:

```python
receipt = contract.setNumber(123, sender=not_owner)
```

And when it fails, Ape shows a stacktrace like this:

```shell
File "$HOME/ApeProjects/ape-project/scripts/fail.py", line 8, in main
receipt = contract.setNumber(5, sender=not_owner)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "$HOME/ApeProjects/ape-project/contracts/VyperContract.vy", line 98, in
setNumber
assert msg.sender == self.owner, "!authorized"
^^^^^^^^^^^^^^^^^^^^^^^

ERROR: (ContractLogicError) !authorized
```

One way to handle exceptions is to simply use `try:` / `except:` blocks:

```python
from ape.exceptions import ContractLogicError

try:
receipt = contract.setNumber(123, sender=not_owner)
except ContractLogicError as err:
receipt = None
print(f"The transaction failed: {err}")
# continue on!
```

If you wish to allow reverts without having Ape raise exceptions, use the `raise_on_revert=False` flag:

```python
>>> receipt = contract.setNumber(123, sender=not_owner, raise_on_revert=False)
>>> receipt.failed
True
>>> receipt.error
ContractLogicError('!authorized')
```

## Dev Messages

Dev messages allow smart-contract authors to save gas by avoiding revert-messages.
If you are using a provider that supports tracing features and a compiler that can detect `dev` messages, and you encounter a revert without a revert-message but it has a dev-message, Ape will show the dev-message:

```python
assert msg.sender == self.owner # dev: !authorized"
```

And you will see a similar stacktrace as if you had used a revert-message.

In Solidity, it might look like this:

```solidity
require(msg.sender == owner); // @dev !authorized
```

## Custom Errors

As of Solidity 0.8.4, custom errors have been introduced to the ABI.
In Ape, custom errors are available on contract-instances.
For example, if you have a contract like:

```solidity
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
error Unauthorized(address unauth_address);
contract MyContract {
address payable owner = payable(msg.sender);
function withdraw() public {
if (msg.sender != owner)
revert Unauthorized(msg.sender);
owner.transfer(address(this).balance);
}
}
```

And if you have an instance of this contract assigned to variable `contract`, you can reference the custom exception by doing:

```python
contract.Unauthorized
```

When invoking `withdraw()` with an unauthorized account using Ape, you will get an exception similar to those from `require()` statements, a subclass of `ContractLogicError`:

```python
contract.withdraw(sender=hacker) # assuming 'hacker' refers to the account without authorization.
```

## Built-in Errors

Besides user-defined `ContractLogicError`s, there are also builtin-errors from compilers, such as bounds-checking of arrays or paying a non-payable method, etc.
These are also `ContractLogicError` sub-classes.
Sometimes, compiler plugins such as `ape-vyper` or `ape-solidity` export these error classes for you to use.

```python
from ape import accounts, Contract
from ape_vyper.exceptions import FallbackNotDefinedError

my_contract = Contract("0x...")
account = accounts.load("test-account")

try:
my_contract(sender=account)
except FallbackNotDefinedError:
print("fallback not defined")
```

Next, learn how to test your contracts' errors using the `ape.reverts` context-manager in the [testing guide](./testing.html#testing-transaction-reverts).
29 changes: 7 additions & 22 deletions docs/userguides/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,10 @@ def test_account_balance(project, owner, receiver, nft):
assert actual == expect
```

## Testing Transaction Failures
## Testing Transaction Reverts

Similar to `pytest.raises()`, you can use `ape.reverts()` to assert that contract transactions fail and revert.
To learn more about reverts in Ape, see the [reverts guide](./reverts.html).

From our earlier example we can see this in action:

Expand Down Expand Up @@ -429,28 +430,12 @@ def foo():

### Custom Errors

As of Solidity 0.8.4, custom errors have been introduced to the ABI.
To make assertions on custom errors, you can use the types defined on your contracts.
In your tests, you can make assertions about custom errors raised.
(For more information on custom errors, [see reverts guide on custom errors](./reverts.html#custom-errors).)

For example, if I have a contract called `MyContract.sol`:

```solidity
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
error Unauthorized(address unauth_address);
contract MyContract {
address payable owner = payable(msg.sender);
function withdraw() public {
if (msg.sender != owner)
revert Unauthorized(msg.sender);
owner.transfer(address(this).balance);
}
}
```

I can ensure unauthorized withdraws are disallowed by writing the following test:
For example, assume a custom exception in a Solidity contract (variable `contract`) is called `Unauthorized`.
It can be accessed via `contract.Unauthorized`.
We can ensure unauthorized withdraws are disallowed by writing the following test:

```python
import ape
Expand Down
28 changes: 26 additions & 2 deletions src/ape/api/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ class TransactionAPI(BaseInterfaceModel):

model_config = ConfigDict(populate_by_name=True)

def __init__(self, *args, **kwargs):
raise_on_revert = kwargs.pop("raise_on_revert", True)
super().__init__(*args, **kwargs)
self._raise_on_revert = raise_on_revert

@field_validator("gas_limit", mode="before")
@classmethod
def validate_gas_limit(cls, value):
Expand Down Expand Up @@ -122,6 +127,14 @@ def validate_value(cls, value):

return int(value)

@property
def raise_on_revert(self) -> bool:
return self._raise_on_revert

@raise_on_revert.setter
def raise_on_revert(self, value):
self._raise_on_revert = value

@property
def total_transfer_value(self) -> int:
"""
Expand Down Expand Up @@ -274,6 +287,7 @@ class ReceiptAPI(ExtraAttributesMixin, BaseInterfaceModel):
status: int
txn_hash: str
transaction: TransactionAPI
_error: Optional[TransactionError] = None

@log_instead_of_fail(default="<ReceiptAPI>")
def __repr__(self) -> str:
Expand Down Expand Up @@ -319,6 +333,14 @@ def debug_logs_lines(self) -> list[str]:
"""
return [" ".join(map(str, ln)) for ln in self.debug_logs_typed]

@property
def error(self) -> Optional[TransactionError]:
return self._error

@error.setter
def error(self, value: TransactionError):
self._error = value

def show_debug_logs(self):
"""
Output debug logs to logging system
Expand Down Expand Up @@ -428,7 +450,6 @@ def await_confirmations(self) -> "ReceiptAPI":
Returns:
:class:`~ape.api.ReceiptAPI`: The receipt that is now confirmed.
"""

try:
self.raise_for_status()
except TransactionError:
Expand All @@ -446,7 +467,10 @@ def await_confirmations(self) -> "ReceiptAPI":
sender_nonce = self.provider.get_nonce(self.sender)
iteration += 1
if iteration == iterations_timeout:
raise TransactionError("Timeout waiting for sender's nonce to increase.")
tx_err = TransactionError("Timeout waiting for sender's nonce to increase.")
self.error = tx_err
if self.transaction.raise_on_revert:
raise tx_err

if self.required_confirmations == 0:
# The transaction might not yet be confirmed but
Expand Down
15 changes: 12 additions & 3 deletions src/ape_ethereum/ecosystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,14 @@ def decode_receipt(self, data: dict) -> ReceiptAPI:
status = TransactionStatusEnum(status)

txn_hash = None
hash_key_choices = ("hash", "txHash", "txnHash", "transactionHash", "transaction_hash")
hash_key_choices = (
"hash",
"txHash",
"txn_hash",
"txnHash",
"transactionHash",
"transaction_hash",
)
for choice in hash_key_choices:
if choice in data:
txn_hash = data[choice]
Expand Down Expand Up @@ -593,7 +600,10 @@ def decode_receipt(self, data: dict) -> ReceiptAPI:
else:
receipt_cls = Receipt

return receipt_cls.model_validate(receipt_kwargs)
error = receipt_kwargs.pop("error", None)
receipt = receipt_cls.model_validate(receipt_kwargs)
receipt.error = error
return receipt

def decode_block(self, data: dict) -> BlockAPI:
data["hash"] = HexBytes(data["hash"]) if data.get("hash") else None
Expand Down Expand Up @@ -844,7 +854,6 @@ def create_transaction(self, **kwargs) -> TransactionAPI:
Returns:
:class:`~ape.api.transactions.TransactionAPI`
"""

# Handle all aliases.
tx_data = dict(kwargs)
tx_data = _correct_key(
Expand Down
Loading

0 comments on commit 489e059

Please sign in to comment.