diff --git a/newsfragments/941.feature.rst b/newsfragments/941.feature.rst new file mode 100644 index 0000000000..9c0f4764fe --- /dev/null +++ b/newsfragments/941.feature.rst @@ -0,0 +1 @@ +Raise `SolidityError` exceptions that contain the revert reason when a `call` fails. diff --git a/tests/core/utilities/test_method_formatters.py b/tests/core/utilities/test_method_formatters.py new file mode 100644 index 0000000000..2bd50cb279 --- /dev/null +++ b/tests/core/utilities/test_method_formatters.py @@ -0,0 +1,65 @@ +import pytest + +from web3._utils.method_formatters import ( + get_error_formatters, + get_revert_reason, +) +from web3._utils.rpc_abi import ( + RPC, +) +from web3.exceptions import ( + SolidityError, +) +from web3.types import ( + RPCResponse, +) + +REVERT_WITH_MSG = RPCResponse({ + 'jsonrpc': '2.0', + 'error': { + 'code': -32015, + 'message': 'VM execution error.', + 'data': ( + 'Reverted ' + '0x08c379a' + '00000000000000000000000000000000000000000000000000000000000000020' + '0000000000000000000000000000000000000000000000000000000000000016' + '6e6f7420616c6c6f77656420746f206d6f6e69746f7200000000000000000000' + ), + }, + 'id': 2987, +}) + +REVERT_WITHOUT_MSG = RPCResponse({ + 'jsonrpc': '2.0', + 'error': { + 'code': -32015, + 'message': 'VM execution error.', + 'data': 'Reverted 0x', + }, + 'id': 2987, +}) + +OTHER_ERROR = RPCResponse({ + "jsonrpc": "2.0", + "error": { + "code": -32601, + "message": "Method not found", + }, + "id": 1, +}) + + +def test_get_revert_reason() -> None: + assert get_revert_reason(REVERT_WITH_MSG) == 'not allowed to monitor' + assert get_revert_reason(REVERT_WITHOUT_MSG) == '' + assert get_revert_reason(OTHER_ERROR) is None + + +def test_get_error_formatters() -> None: + formatters = get_error_formatters(RPC.eth_call) + with pytest.raises(SolidityError, match='not allowed to monitor'): + formatters(REVERT_WITH_MSG) + with pytest.raises(SolidityError): + formatters(REVERT_WITHOUT_MSG) + assert formatters(OTHER_ERROR) == OTHER_ERROR diff --git a/web3/_utils/method_formatters.py b/web3/_utils/method_formatters.py index 4ad32eea9f..53b18cc737 100644 --- a/web3/_utils/method_formatters.py +++ b/web3/_utils/method_formatters.py @@ -67,8 +67,12 @@ from web3.datastructures import ( AttributeDict, ) +from web3.exceptions import ( + SolidityError, +) from web3.types import ( RPCEndpoint, + RPCResponse, TReturn, ) @@ -431,7 +435,6 @@ def apply_list_to_array_formatter(formatter: Any) -> Callable[..., Any]: RPC.net_peerCount: to_integer_if_hex, } - ATTRDICT_FORMATTER = { '*': apply_formatter_if(is_dict and not_attrdict, AttributeDict.recursive) } @@ -452,6 +455,54 @@ def apply_list_to_array_formatter(formatter: Any) -> Callable[..., Any]: ABI_REQUEST_FORMATTERS = abi_request_formatters(STANDARD_NORMALIZERS, RPC_ABIS) +def get_revert_reason(response: RPCResponse) -> str: + """ + Parse revert reason from response, return None if no revert happened. + + If a revert happened, but no message has been given, return an empty string. + + Reverts contain a `data` attribute with the following layout: + "Reverted " + Function selector for Error(string): 08c379a (4 bytes) + Data offset: 32 (32 bytes) + String length (32 bytes) + Reason strong (padded, use string length from above to get meaningful part) + + See also https://solidity.readthedocs.io/en/v0.6.3/control-structures.html#revert + """ + assert 'error' in response + if not isinstance(response['error'], dict): + return None + + data = response['error'].get('data', '') + + if data == 'Reverted 0x': + return '' + + # "Reverted", function selector and offset are always the same for revert errors + prefix = 'Reverted 0x08c379a00000000000000000000000000000000000000000000000000000000000000020' + if not data.startswith(prefix): + return None + + reason_length = int(data[len(prefix):len(prefix) + 64], 16) + reason = data[len(prefix) + 64:len(prefix) + 64 + reason_length * 2] + return bytes.fromhex(reason).decode('utf8') + + +def raise_solidity_error_on_revert(response: RPCResponse) -> RPCResponse: + revert_reason = get_revert_reason(response) + if revert_reason is None: + return response + if revert_reason == '': + raise SolidityError() + raise SolidityError(revert_reason) + + +ERROR_FORMATTERS: Dict[RPCEndpoint, Callable[..., Any]] = { + RPC.eth_call: raise_solidity_error_on_revert, +} + + @to_tuple def combine_formatters( formatter_maps: Collection[Dict[RPCEndpoint, Callable[..., TReturn]]], method_name: RPCEndpoint @@ -487,10 +538,9 @@ def get_result_formatters( def get_error_formatters( method_name: Union[RPCEndpoint, Callable[..., RPCEndpoint]] -) -> Dict[str, Callable[..., Any]]: +) -> Callable[..., Any]: # Note error formatters work on the full response dict - # TODO - test this function - error_formatter_maps = () + error_formatter_maps = (ERROR_FORMATTERS, ) formatters = combine_formatters(error_formatter_maps, method_name) return compose(*formatters) diff --git a/web3/exceptions.py b/web3/exceptions.py index 2241d887e3..b363206fbe 100644 --- a/web3/exceptions.py +++ b/web3/exceptions.py @@ -169,3 +169,11 @@ class InvalidEventABI(ValueError): Raised when the event ABI is invalid. """ pass + + +class SolidityError(ValueError): + # Inherits from ValueError for backwards compatibility + """ + Raised on a solidity require/revert + """ + pass diff --git a/web3/manager.py b/web3/manager.py index 54b3e94a73..89909482b0 100644 --- a/web3/manager.py +++ b/web3/manager.py @@ -149,7 +149,7 @@ def request_blocking( response = self._make_request(method, params) if "error" in response: - apply_error_formatters(error_formatters, response) + response = apply_error_formatters(error_formatters, response) raise ValueError(response["error"]) return response['result'] diff --git a/web3/types.py b/web3/types.py index 66e042a38f..c218c01e83 100644 --- a/web3/types.py +++ b/web3/types.py @@ -109,6 +109,7 @@ class EventData(TypedDict): class RPCError(TypedDict): code: int message: str + data: Optional[str] class RPCResponse(TypedDict, total=False):