Skip to content

Commit

Permalink
Merge pull request #1728 from eth-brownie/typed-errors
Browse files Browse the repository at this point in the history
Support for custom / typed errors
  • Loading branch information
iamdefinitelyahuman committed Jan 31, 2024
2 parents 2c83611 + 6b39b86 commit 1158fea
Show file tree
Hide file tree
Showing 7 changed files with 95 additions and 80 deletions.
82 changes: 69 additions & 13 deletions brownie/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
#!/usr/bin/python3

import json
import sys
from typing import Optional, Type
from pathlib import Path
from typing import Dict, List, Optional, Type

import eth_abi
import psutil
import yaml
from hexbytes import HexBytes

import brownie
from brownie._config import _get_data_folder
from brownie.convert.utils import build_function_selector, get_type_strings

# network

ERROR_SIG = "0x08c379a0"


# error codes used in Solidity >=0.8.0
# docs.soliditylang.org/en/v0.8.0/control-structures.html#panic-via-assert-and-error-via-require
SOLIDITY_ERROR_CODES = {
1: "Failed assertion",
17: "Integer overflow",
18: "Division or modulo by zero",
33: "Conversion to enum out of bounds",
24: "Access to storage byte array that is incorrectly encoded",
49: "Pop from empty array",
50: "Index out of range",
65: "Attempted to allocate too much memory",
81: "Call to zero-initialized variable of internal function type",
}


class UnknownAccount(Exception):
pass

Expand Down Expand Up @@ -97,19 +116,9 @@ def __init__(self, exc: ValueError) -> None:

self.message: str = exc["message"].rstrip(".")

if isinstance(exc["data"], str):
# handle parity exceptions - this logic probably is not perfect
if not exc["data"].startswith(ERROR_SIG):
err_msg = exc["data"]
if err_msg.endswith("0x"):
err_msg = exc["data"][:-2].strip()
raise ValueError(f"{self.message}: {err_msg}") from None

if isinstance(exc["data"], str) and exc["data"].startswith("0x"):
self.revert_type = "revert"
err_msg = exc["data"][len(ERROR_SIG) :]
(err_msg,) = eth_abi.decode(["string"], HexBytes(err_msg))
self.revert_msg = err_msg

self.revert_msg = decode_typed_error(exc["data"])
return

try:
Expand All @@ -125,6 +134,9 @@ def __init__(self, exc: ValueError) -> None:
self.pc -= 1

self.revert_msg = data.get("reason")
if isinstance(data.get("reason"), str) and data["reason"].startswith("0x"):
self.revert_msg = decode_typed_error(data["reason"])

self.dev_revert_msg = brownie.project.build._get_dev_revert(self.pc)
if self.revert_msg is None and self.revert_type in ("revert", "invalid opcode"):
self.revert_msg = self.dev_revert_msg
Expand Down Expand Up @@ -239,3 +251,47 @@ class BrownieTestWarning(Warning):

class BrownieConfigWarning(Warning):
pass


def __get_path() -> Path:
return _get_data_folder().joinpath("errors.json")


def parse_errors_from_abi(abi: List):
updated = False
for item in [i for i in abi if i.get("type", None) == "error"]:
selector = build_function_selector(item)
if selector in _errors:
continue
updated = True
_errors[selector] = item

if updated:
with __get_path().open("w") as fp:
json.dump(_errors, fp, sort_keys=True, indent=2)


_errors: Dict = {ERROR_SIG: {"name": "Error", "inputs": [{"name": "", "type": "string"}]}}

try:
with __get_path().open() as fp:
_errors.update(json.load(fp))
except (FileNotFoundError, json.decoder.JSONDecodeError):
pass


def decode_typed_error(data: str) -> str:
selector = data[:10]
if selector == "0x4e487b71":
# special case, solidity compiler panics
error_code = int(data[4:].hex(), 16)
return SOLIDITY_ERROR_CODES.get(error_code, f"Unknown compiler Panic: {error_code}")
if selector in _errors:
types_list = get_type_strings(_errors[selector]["inputs"])
result = eth_abi.decode(types_list, HexBytes(data)[4:])
if selector == ERROR_SIG:
return result[0]
else:
return f"{_errors[selector]['name']}: {', '.join(result)}"
else:
return f"Unknown typed error: {data}"
8 changes: 4 additions & 4 deletions brownie/network/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,9 +480,9 @@ def _check_for_revert(self, tx: Dict) -> None:
skip_keys = {"gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"}
web3.eth.call({k: v for k, v in tx.items() if k not in skip_keys and v})
except ValueError as exc:
msg = exc.args[0]["message"] if isinstance(exc.args[0], dict) else str(exc)
exc = VirtualMachineError(exc)
raise ValueError(
f"Execution reverted during call: '{msg}'. This transaction will likely revert. "
f"Execution reverted during call: '{exc.revert_msg}'. This transaction will likely revert. "
"If you wish to broadcast, include `allow_revert:True` as a transaction parameter.",
) from None

Expand Down Expand Up @@ -617,9 +617,9 @@ def estimate_gas(
if revert_gas_limit:
return revert_gas_limit

msg = exc.args[0]["message"] if isinstance(exc.args[0], dict) else str(exc)
exc = VirtualMachineError(exc)
raise ValueError(
f"Gas estimation failed: '{msg}'. This transaction will likely revert. "
f"Gas estimation failed: '{exc.revert_msg}'. This transaction will likely revert. "
"If you wish to broadcast, you must set the gas limit manually."
)

Expand Down
33 changes: 11 additions & 22 deletions brownie/network/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@
ContractNotFound,
UndeployedLibrary,
VirtualMachineError,
decode_typed_error,
parse_errors_from_abi,
)
from brownie.project import compiler, ethpm
from brownie.project.compiler.solidity import SOLIDITY_ERROR_CODES
from brownie.project.flattener import Flattener
from brownie.typing import AccountsType, TransactionReceiptType
from brownie.utils import color
Expand Down Expand Up @@ -86,12 +87,11 @@
"aurorascan": "AURORASCAN_TOKEN",
"moonscan": "MOONSCAN_TOKEN",
"gnosisscan": "GNOSISSCAN_TOKEN",
"base": "BASESCAN_TOKEN"
"base": "BASESCAN_TOKEN",
}


class _ContractBase:

_dir_color = "bright magenta"

def __init__(self, project: Any, build: Dict, sources: Dict) -> None:
Expand All @@ -106,6 +106,7 @@ def __init__(self, project: Any, build: Dict, sources: Dict) -> None:
self.signatures = {
i["name"]: build_function_selector(i) for i in self.abi if i["type"] == "function"
}
parse_errors_from_abi(self.abi)

@property
def abi(self) -> List:
Expand Down Expand Up @@ -508,7 +509,6 @@ def _slice_source(self, source: str, offset: list) -> str:


class ContractConstructor:

_dir_color = "bright magenta"

def __init__(self, parent: "ContractContainer", name: str) -> None:
Expand Down Expand Up @@ -562,7 +562,7 @@ def __call__(
required_confs=tx["required_confs"],
allow_revert=tx.get("allow_revert"),
publish_source=publish_source,
silent=silent
silent=silent,
)

@staticmethod
Expand Down Expand Up @@ -1621,7 +1621,6 @@ def info(self) -> None:


class _ContractMethod:

_dir_color = "bright magenta"

def __init__(
Expand Down Expand Up @@ -1700,23 +1699,14 @@ def call(
except ValueError as e:
raise VirtualMachineError(e) from None

selector = HexBytes(data)[:4].hex()

if selector == "0x08c379a0":
revert_str = eth_abi.decode(["string"], HexBytes(data)[4:])[0]
raise ValueError(f"Call reverted: {revert_str}")
elif selector == "0x4e487b71":
error_code = int(HexBytes(data)[4:].hex(), 16)
if error_code in SOLIDITY_ERROR_CODES:
revert_str = SOLIDITY_ERROR_CODES[error_code]
else:
revert_str = f"Panic (error code: {error_code})"
raise ValueError(f"Call reverted: {revert_str}")
if self.abi["outputs"] and not data:
raise ValueError("No data was returned - the call likely reverted")
return self.decode_output(data)
try:
return self.decode_output(data)
except:
raise ValueError(f"Call reverted: {decode_typed_error(data)}") from None

def transact(self, silent: bool = False, *args: Tuple) -> TransactionReceiptType:
def transact(self, silent: bool = False, *args: Tuple) -> TransactionReceiptType:
"""
Broadcast a transaction that calls this contract method.
Expand Down Expand Up @@ -1751,7 +1741,7 @@ def transact(self, silent: bool = False, *args: Tuple) -> TransactionReceiptTyp
required_confs=tx["required_confs"],
data=self.encode_input(*args),
allow_revert=tx["allow_revert"],
silent=silent
silent=silent,
)

def decode_input(self, hexstr: str) -> List:
Expand Down Expand Up @@ -1970,7 +1960,6 @@ def _get_tx(owner: Optional[AccountsType], args: Tuple) -> Tuple:
def _get_method_object(
address: str, abi: Dict, name: str, owner: Optional[AccountsType], natspec: Dict
) -> Union["ContractCall", "ContractTx"]:

if "constant" in abi:
constant = abi["constant"]
else:
Expand Down
2 changes: 1 addition & 1 deletion brownie/network/middlewares/ganache7.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def process_request(self, make_request: Callable, method: str, params: List) ->
# "VM Exception while processing transaction: {reason} {message}"
msg = result["error"]["message"].split(": ", maxsplit=1)[-1]
if msg.startswith("revert"):
data = {"error": "revert", "reason": msg[7:]}
data = {"error": "revert", "reason": result["error"]["data"]}
else:
data = {"error": msg, "reason": None}
result["error"]["data"] = {"0x": data}
Expand Down
3 changes: 3 additions & 0 deletions brownie/network/middlewares/hardhat.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ def process_request(self, make_request: Callable, method: str, params: List) ->
data.update({"error": "revert", "reason": message[7:]})
elif "reverted with reason string '" in message:
data.update(error="revert", reason=re.findall(".*?'(.*)'$", message)[0])
elif "reverted with an unrecognized custom error" in message:
message = message[message.index("0x") : -1]
data.update(error="revert", reason=message)
else:
data["error"] = message
return result
25 changes: 6 additions & 19 deletions brownie/network/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,9 @@

from brownie._config import CONFIG
from brownie.convert import EthAddress, Wei
from brownie.exceptions import ContractNotFound, RPCRequestError
from brownie.exceptions import ContractNotFound, RPCRequestError, decode_typed_error
from brownie.project import build
from brownie.project import main as project_main
from brownie.project.compiler.solidity import SOLIDITY_ERROR_CODES
from brownie.project.sources import highlight_source
from brownie.test import coverage
from brownie.utils import color
Expand Down Expand Up @@ -632,8 +631,8 @@ def _get_trace(self) -> None:
try:
trace = web3.provider.make_request( # type: ignore
# Set enableMemory to all RPC as anvil return the memory key
"debug_traceTransaction", (self.txid, {
"disableStorage": CONFIG.mode != "console", "enableMemory": True})
"debug_traceTransaction",
(self.txid, {"disableStorage": CONFIG.mode != "console", "enableMemory": True}),
)
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
msg = f"Encountered a {type(e).__name__} while requesting "
Expand Down Expand Up @@ -679,7 +678,8 @@ def _get_trace(self) -> None:
# Check if gasCost is hex before converting.
if isinstance(step["gasCost"], str):
step["gasCost"] = int.from_bytes(
HexBytes(step["gasCost"]), "big", signed=True)
HexBytes(step["gasCost"]), "big", signed=True
)
if isinstance(step["pc"], str): # Check if pc is hex before converting.
step["pc"] = int(step["pc"], 16)

Expand Down Expand Up @@ -718,20 +718,7 @@ def _reverted_trace(self, trace: Sequence) -> None:
if step["op"] == "REVERT" and int(step["stack"][-2], 16):
# get returned error string from stack
data = _get_memory(step, -1)

selector = data[:4].hex()

if selector == "0x4e487b71": # keccak of Panic(uint256)
error_code = int(data[4:].hex(), 16)
if error_code in SOLIDITY_ERROR_CODES:
self._revert_msg = SOLIDITY_ERROR_CODES[error_code]
else:
self._revert_msg = f"Panic (error code: {error_code})"
elif selector == "0x08c379a0": # keccak of Error(string)
self._revert_msg = decode(["string"], data[4:])[0]
else:
# TODO: actually parse the data
self._revert_msg = f"typed error: {data.hex()}"
self._revert_msg = decode_typed_error(data.hex())

elif self.contract_address:
self._revert_msg = "invalid opcode" if step["op"] == "INVALID" else ""
Expand Down
Loading

0 comments on commit 1158fea

Please sign in to comment.