diff --git a/newsfragments/1002.bugfix.rst b/newsfragments/1002.bugfix.rst new file mode 100644 index 0000000000..4a9f55dc9c --- /dev/null +++ b/newsfragments/1002.bugfix.rst @@ -0,0 +1,3 @@ +Improve contract call errors when a contract address has code but returns +insufficient data for ABI decoding, raising ``ContractLogicError`` instead of a +generic decode failure. diff --git a/tests/core/contracts/test_contract_call_interface.py b/tests/core/contracts/test_contract_call_interface.py index 8d09b0535d..c4d3022d84 100644 --- a/tests/core/contracts/test_contract_call_interface.py +++ b/tests/core/contracts/test_contract_call_interface.py @@ -41,6 +41,7 @@ ABIReceiveNotFound, BadFunctionCallOutput, BlockNumberOutOfRange, + ContractLogicError, InvalidAddress, MismatchedABI, NameNotFound, @@ -498,8 +499,10 @@ def test_call_rejects_invalid_ens_name(address_reflector_contract, call): def test_call_missing_function(mismatched_math_contract, call): # note: contract being called needs to have a fallback function # (StringContract in this case) - expected_missing_function_error_message = "Could not decode contract function call" - with pytest.raises(BadFunctionCallOutput) as exception_info: + expected_missing_function_error_message = ( + "Contract call failed because execution reverted or returned no data." + ) + with pytest.raises(ContractLogicError) as exception_info: call(contract=mismatched_math_contract, contract_function="return13") assert expected_missing_function_error_message in str(exception_info.value) @@ -1788,8 +1791,10 @@ async def test_async_call_rejects_invalid_ens_name( async def test_async_call_missing_function(async_mismatched_math_contract, async_call): # note: contract being called needs to have a fallback function # (StringContract in this case) - expected_missing_function_error_message = "Could not decode contract function call" - with pytest.raises(BadFunctionCallOutput) as exception_info: + expected_missing_function_error_message = ( + "Contract call failed because execution reverted or returned no data." + ) + with pytest.raises(ContractLogicError) as exception_info: await async_call( contract=async_mismatched_math_contract, contract_function="return13", diff --git a/web3/contract/utils.py b/web3/contract/utils.py index c29f392369..b18648c4b8 100644 --- a/web3/contract/utils.py +++ b/web3/contract/utils.py @@ -53,6 +53,7 @@ ) from web3.exceptions import ( BadFunctionCallOutput, + ContractLogicError, Web3ValueError, ) from web3.types import ( @@ -79,6 +80,19 @@ ACCEPTABLE_EMPTY_STRINGS = ["0x", b"0x", "", b""] +def _is_code_missing(code: bytes) -> bool: + return code in ACCEPTABLE_EMPTY_STRINGS + + +def _has_insufficient_output_data( + return_data: bytes, + output_types: Sequence[TypeStr], +) -> bool: + if return_data in ACCEPTABLE_EMPTY_STRINGS: + return True + return len(return_data) < 32 * len(output_types) + + @curry def format_contract_call_return_data_curried( async_w3: Union["AsyncWeb3[Any]", "Web3"], @@ -201,20 +215,23 @@ def call_contract_function( except DecodingError as e: # Provide a more helpful error message than the one provided by # eth-abi-utils - is_missing_code_error = ( - return_data in ACCEPTABLE_EMPTY_STRINGS - and w3.eth.get_code(address) in ACCEPTABLE_EMPTY_STRINGS - ) - if is_missing_code_error: + code = w3.eth.get_code(address) + if _is_code_missing(code): msg = ( "Could not transact with/call contract function, is contract " "deployed correctly and chain synced?" ) - else: + raise BadFunctionCallOutput(msg) from e + if output_types and _has_insufficient_output_data(return_data, output_types): msg = ( - f"Could not decode contract function call to {abi_element_identifier} " - f"with return data: {str(return_data)}, output_types: {output_types}" + "Contract call failed because execution reverted or returned no " + "data." ) + raise ContractLogicError(msg, data=str(return_data)) from e + msg = ( + f"Could not decode contract function call to {abi_element_identifier} " + f"with return data: {str(return_data)}, output_types: {output_types}" + ) raise BadFunctionCallOutput(msg) from e _normalizers = itertools.chain( @@ -499,20 +516,23 @@ async def async_call_contract_function( except DecodingError as e: # Provide a more helpful error message than the one provided by # eth-abi-utils - is_missing_code_error = ( - return_data in ACCEPTABLE_EMPTY_STRINGS - and await async_w3.eth.get_code(address) in ACCEPTABLE_EMPTY_STRINGS - ) - if is_missing_code_error: + code = await async_w3.eth.get_code(address) + if _is_code_missing(code): msg = ( "Could not transact with/call contract function, is contract " "deployed correctly and chain synced?" ) - else: + raise BadFunctionCallOutput(msg) from e + if output_types and _has_insufficient_output_data(return_data, output_types): msg = ( - f"Could not decode contract function call to {abi_element_identifier} " - f"with return data: {str(return_data)}, output_types: {output_types}" + "Contract call failed because execution reverted or returned no " + "data." ) + raise ContractLogicError(msg, data=str(return_data)) from e + msg = ( + f"Could not decode contract function call to {abi_element_identifier} " + f"with return data: {str(return_data)}, output_types: {output_types}" + ) raise BadFunctionCallOutput(msg) from e _normalizers = itertools.chain(