From 4575931533dccb913cd1bd5f7e1053d568683431 Mon Sep 17 00:00:00 2001 From: Ben Hauser Date: Wed, 31 Jan 2024 03:07:25 +0400 Subject: [PATCH 1/6] feat: decode cusom typed errors --- brownie/exceptions.py | 63 +++++++++++++++++++++++++++++-------- brownie/network/contract.py | 21 +++++++------ 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/brownie/exceptions.py b/brownie/exceptions.py index 7b28d428f..5fdfd2009 100644 --- a/brownie/exceptions.py +++ b/brownie/exceptions.py @@ -1,7 +1,9 @@ #!/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 @@ -9,6 +11,8 @@ 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 @@ -97,19 +101,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 = self._decode_custom_error(exc["data"]) return try: @@ -125,6 +119,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 @@ -239,3 +236,43 @@ 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 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}" diff --git a/brownie/network/contract.py b/brownie/network/contract.py index 2b84b459d..1041bca6d 100644 --- a/brownie/network/contract.py +++ b/brownie/network/contract.py @@ -37,7 +37,12 @@ from web3.datastructures import AttributeDict from web3.types import LogReceipt -from brownie._config import BROWNIE_FOLDER, CONFIG, REQUEST_HEADERS, _load_project_compiler_config +from brownie._config import ( + BROWNIE_FOLDER, + CONFIG, + REQUEST_HEADERS, + _load_project_compiler_config, +) from brownie.convert.datatypes import Wei from brownie.convert.normalize import format_input, format_output from brownie.convert.utils import ( @@ -52,6 +57,7 @@ ContractNotFound, UndeployedLibrary, VirtualMachineError, + parse_errors_from_abi, ) from brownie.project import compiler, ethpm from brownie.project.compiler.solidity import SOLIDITY_ERROR_CODES @@ -86,12 +92,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: @@ -106,6 +111,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: @@ -508,7 +514,6 @@ def _slice_source(self, source: str, offset: list) -> str: class ContractConstructor: - _dir_color = "bright magenta" def __init__(self, parent: "ContractContainer", name: str) -> None: @@ -562,7 +567,7 @@ def __call__( required_confs=tx["required_confs"], allow_revert=tx.get("allow_revert"), publish_source=publish_source, - silent=silent + silent=silent, ) @staticmethod @@ -1621,7 +1626,6 @@ def info(self) -> None: class _ContractMethod: - _dir_color = "bright magenta" def __init__( @@ -1716,7 +1720,7 @@ def call( raise ValueError("No data was returned - the call likely reverted") return self.decode_output(data) - 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. @@ -1751,7 +1755,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: @@ -1970,7 +1974,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: From 3e29f3e02b74afa6cfec73ecd245e673ef42d7a6 Mon Sep 17 00:00:00 2001 From: Ben Hauser Date: Wed, 31 Jan 2024 03:08:05 +0400 Subject: [PATCH 2/6] fix: error decoding on failed call/gas estimate --- brownie/network/account.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/brownie/network/account.py b/brownie/network/account.py index df0cff4fd..8e017bab8 100644 --- a/brownie/network/account.py +++ b/brownie/network/account.py @@ -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 @@ -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." ) From 6010ae9319e2a6fe8a4b2d18356fd88337a2b489 Mon Sep 17 00:00:00 2001 From: Ben Hauser Date: Wed, 31 Jan 2024 03:10:31 +0400 Subject: [PATCH 3/6] fix: strip hardhat revert message for typed error --- brownie/network/middlewares/hardhat.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/brownie/network/middlewares/hardhat.py b/brownie/network/middlewares/hardhat.py index 718780358..cc0440035 100644 --- a/brownie/network/middlewares/hardhat.py +++ b/brownie/network/middlewares/hardhat.py @@ -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 From 11f356cb34e7f250311dae539ac2c71d01352f5d Mon Sep 17 00:00:00 2001 From: Ben Hauser Date: Wed, 31 Jan 2024 03:11:32 +0400 Subject: [PATCH 4/6] fix: ganache-specific error message --- brownie/network/middlewares/ganache7.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/brownie/network/middlewares/ganache7.py b/brownie/network/middlewares/ganache7.py index d899dfce2..33150a687 100644 --- a/brownie/network/middlewares/ganache7.py +++ b/brownie/network/middlewares/ganache7.py @@ -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} From c68a62e45ed8a51ff1a7ea5e2979e3b965d13dd6 Mon Sep 17 00:00:00 2001 From: Ben Hauser Date: Wed, 31 Jan 2024 04:01:21 +0400 Subject: [PATCH 5/6] refactor: aggregate logic for decoding typed errors --- brownie/exceptions.py | 21 ++++++++++++++++++++- brownie/network/contract.py | 20 ++++++-------------- brownie/network/transaction.py | 25 ++++++------------------- brownie/project/compiler/solidity.py | 22 +--------------------- 4 files changed, 33 insertions(+), 55 deletions(-) diff --git a/brownie/exceptions.py b/brownie/exceptions.py index 5fdfd2009..66d7c532a 100644 --- a/brownie/exceptions.py +++ b/brownie/exceptions.py @@ -19,6 +19,21 @@ 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 @@ -103,7 +118,7 @@ def __init__(self, exc: ValueError) -> None: if isinstance(exc["data"], str) and exc["data"].startswith("0x"): self.revert_type = "revert" - self.revert_msg = self._decode_custom_error(exc["data"]) + self.revert_msg = decode_typed_error(exc["data"]) return try: @@ -267,6 +282,10 @@ def parse_errors_from_abi(abi: List): 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:]) diff --git a/brownie/network/contract.py b/brownie/network/contract.py index 1041bca6d..4def75ecf 100644 --- a/brownie/network/contract.py +++ b/brownie/network/contract.py @@ -58,9 +58,10 @@ UndeployedLibrary, VirtualMachineError, parse_errors_from_abi, + decode_typed_error, ) 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 @@ -1704,21 +1705,12 @@ 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: """ diff --git a/brownie/network/transaction.py b/brownie/network/transaction.py index 73ac8e6c8..f7e3d8c61 100644 --- a/brownie/network/transaction.py +++ b/brownie/network/transaction.py @@ -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 @@ -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 " @@ -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) @@ -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 "" diff --git a/brownie/project/compiler/solidity.py b/brownie/project/compiler/solidity.py index 85a55b062..0e497a52f 100644 --- a/brownie/project/compiler/solidity.py +++ b/brownie/project/compiler/solidity.py @@ -12,7 +12,7 @@ from solcast.nodes import NodeBase, is_inside_offset from brownie._config import EVM_EQUIVALENTS -from brownie.exceptions import CompilerError, IncompatibleSolcVersion +from brownie.exceptions import CompilerError, IncompatibleSolcVersion, SOLIDITY_ERROR_CODES # noqa from brownie.project.compiler.utils import _get_alias, expand_source_map from . import sources @@ -32,20 +32,6 @@ ("byzantium", Version("0.4.0")), ] -# 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", -} - def get_version() -> Version: return solcx.get_solc_version(with_commit_hash=True) @@ -54,7 +40,6 @@ def get_version() -> Version: def compile_from_input_json( input_json: Dict, silent: bool = True, allow_paths: Optional[str] = None ) -> Dict: - """ Compiles contracts from a standard input json. @@ -131,7 +116,6 @@ def find_solc_versions( install_latest: bool = False, silent: bool = True, ) -> Dict: - """ Analyzes contract pragmas and determines which solc version(s) to use. @@ -199,7 +183,6 @@ def find_best_solc_version( install_latest: bool = False, silent: bool = True, ) -> str: - """ Analyzes contract pragmas and finds the best version compatible with all sources. @@ -217,7 +200,6 @@ def find_best_solc_version( available_versions, installed_versions = _get_solc_version_list() for path, source in contract_sources.items(): - pragma_spec = sources.get_pragma_spec(source, path) installed_versions = [i for i in installed_versions if i in pragma_spec] available_versions = [i for i in available_versions if i in pragma_spec] @@ -528,7 +510,6 @@ def _find_revert_offset( fn_node: NodeBase, fn_name: Optional[str], ) -> None: - # attempt to infer a source offset for reverts that do not have one if source_map: @@ -550,7 +531,6 @@ def _find_revert_offset( # get the offset of the next instruction next_offset = None if source_map and source_map[0][2] != -1: - next_offset = (source_map[0][0], source_map[0][0] + source_map[0][1]) # if the next instruction offset is not equal to the offset of the active function, From 6b39b861315c6185db6dfdf8f06fe97eaea343d7 Mon Sep 17 00:00:00 2001 From: Ben Hauser Date: Wed, 31 Jan 2024 04:05:19 +0400 Subject: [PATCH 6/6] chore: lint --- brownie/network/contract.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/brownie/network/contract.py b/brownie/network/contract.py index 4def75ecf..e7686f074 100644 --- a/brownie/network/contract.py +++ b/brownie/network/contract.py @@ -37,12 +37,7 @@ from web3.datastructures import AttributeDict from web3.types import LogReceipt -from brownie._config import ( - BROWNIE_FOLDER, - CONFIG, - REQUEST_HEADERS, - _load_project_compiler_config, -) +from brownie._config import BROWNIE_FOLDER, CONFIG, REQUEST_HEADERS, _load_project_compiler_config from brownie.convert.datatypes import Wei from brownie.convert.normalize import format_input, format_output from brownie.convert.utils import ( @@ -57,11 +52,10 @@ ContractNotFound, UndeployedLibrary, VirtualMachineError, - parse_errors_from_abi, decode_typed_error, + parse_errors_from_abi, ) from brownie.project import compiler, ethpm - from brownie.project.flattener import Flattener from brownie.typing import AccountsType, TransactionReceiptType from brownie.utils import color