Repository: ethereum/web3.py

Issue:
Add support for custom errors or provide full RPC response if transaction is reverted
* Version: 6.0.0b9
* Python: 3.10.9
* OS: osx

### What was wrong?
I need to call a contract method and get revert data
```
from web3 import Web3
w3 = Web3(Web3.HTTPProvider("https://eth-goerli.public.blastapi.io"))
contract = w3.eth.contract(address=address, abi=abi)
contract.functions.alwaysReverts().call()
```
**Unfortunately** the exception raised with `call()` method provides no information about triggered custom error and the data passed as the error parameters:
```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  ...
    File "/Users/alexander/Desktop/FastAPI/venv/lib/python3.10/site-packages/web3/_utils/method_formatters.py", line 636, in     raise_solidity_error_on_revert
    raise ContractLogicError(response["error"]["message"])
web3.exceptions.ContractLogicError: execution reverted
```

If we look at the variable `response` used to raise the exception, we see that this data is contained in `response["error"]["data"]` 
```
>>> response
{'jsonrpc': '2.0', 'id': 1, 'error': {'code': 3, 'message': 'execution reverted', 'data': '0x8f046edb0000000000000000000000000000000000000000000000000000000063d1a1a400000000000000000000000000000000000000000000000000000000000003ff'}}
```

But it isn't used in the exception and there is no access to raw response data but there should be a proper way to get it




Discussion:
Yeah, agreed. Thanks for raising the issue!

Resolving patch:
diff --git a/web3/_utils/method_formatters.py b/web3/_utils/method_formatters.py
--- a/web3/_utils/method_formatters.py
+++ b/web3/_utils/method_formatters.py
@@ -724,7 +724,9 @@ def raise_contract_logic_error_on_revert(response: RPCResponse) -> RPCResponse:
 
     # Ganache case:
     if isinstance(data, dict) and response["error"].get("message"):
-        raise ContractLogicError(f'execution reverted: {response["error"]["message"]}')
+        raise ContractLogicError(
+            f'execution reverted: {response["error"]["message"]}', data=data
+        )
 
     # Parity/OpenEthereum case:
     if data.startswith("Reverted "):
@@ -735,14 +737,17 @@ def raise_contract_logic_error_on_revert(response: RPCResponse) -> RPCResponse:
                 # Special case for this form: 'Reverted 0x...'
                 receipt = data.split(" ")[1][2:]
                 revert_reason = bytes.fromhex(receipt).decode("utf-8")
-                raise ContractLogicError(f"execution reverted: {revert_reason}")
+                raise ContractLogicError(
+                    f"execution reverted: {revert_reason}", data=data
+                )
             else:
-                raise ContractLogicError("execution reverted")
+                raise ContractLogicError("execution reverted", data=data)
 
         reason_length = int(data[len(prefix) : len(prefix) + 64], 16)
         reason = data[len(prefix) + 64 : len(prefix) + 64 + reason_length * 2]
         raise ContractLogicError(
-            f'execution reverted: {bytes.fromhex(reason).decode("utf8")}'
+            f'execution reverted: {bytes.fromhex(reason).decode("utf8")}',
+            data=data,
         )
 
     # --- EIP-3668 | CCIP Read --- #
@@ -756,22 +761,25 @@ def raise_contract_logic_error_on_revert(response: RPCResponse) -> RPCResponse:
         offchain_lookup_payload = dict(
             zip(OFFCHAIN_LOOKUP_FIELDS.keys(), abi_decoded_data)
         )
-        raise OffchainLookup(offchain_lookup_payload)
+        raise OffchainLookup(offchain_lookup_payload, data=data)
 
     # Solidity 0.8.4 introduced custom error messages that allow args to
     # be passed in (or not). See:
     # https://blog.soliditylang.org/2021/04/21/custom-errors/
     if len(data) >= 10 and not data[:10] == "0x08c379a0":
-        # raising along with the data value to allow processing in user code
-        raise ContractCustomError(data)
+        # Raise with data as both the message and the data for backwards
+        # compatibility and so that data can be accessed via 'data' attribute
+        # on the ContractCustomError exception
+        raise ContractCustomError(data, data=data)
 
     # Geth case:
     if "message" in response["error"] and response["error"].get("code", "") == 3:
-        raise ContractLogicError(response["error"]["message"])
+        message = response["error"]["message"]
+        raise ContractLogicError(message, data=data)
 
     # Geth Revert without error message case:
     if "execution reverted" in response["error"].get("message"):
-        raise ContractLogicError("execution reverted")
+        raise ContractLogicError("execution reverted", data=data)
 
     return response
 
diff --git a/web3/exceptions.py b/web3/exceptions.py
--- a/web3/exceptions.py
+++ b/web3/exceptions.py
@@ -3,6 +3,7 @@
 from typing import (
     Any,
     Dict,
+    Optional,
 )
 
 from eth_utils import (
@@ -250,21 +251,28 @@ class ContractLogicError(Web3Exception):
     Raised on a contract revert error
     """
 
+    def __init__(self, message: Optional[str] = None, data: Optional[str] = None):
+        self.message = message
+        self.data = data
+
 
 class ContractCustomError(ContractLogicError):
     """
     Raised on a contract revert custom error
     """
 
+    pass
+
 
 class OffchainLookup(ContractLogicError):
     """
     Raised when a contract reverts with OffchainLookup as described in EIP-3668
     """
 
-    def __init__(self, payload: Dict[str, Any]) -> None:
+    def __init__(self, payload: Dict[str, Any], data: Optional[str] = None) -> None:
         self.payload = payload
-        super().__init__()
+        self.data = data
+        super().__init__(data=data)
 
 
 class InvalidTransaction(Web3Exception):
diff --git a/web3/providers/eth_tester/defaults.py b/web3/providers/eth_tester/defaults.py
--- a/web3/providers/eth_tester/defaults.py
+++ b/web3/providers/eth_tester/defaults.py
@@ -42,6 +42,9 @@
     excepts,
 )
 
+from web3 import (
+    Web3,
+)
 from web3._utils.method_formatters import (
     OFFCHAIN_LOOKUP_FIELDS,
 )
@@ -95,7 +98,9 @@ def call_eth_tester(
             offchain_lookup_payload = dict(
                 zip(OFFCHAIN_LOOKUP_FIELDS.keys(), abi_decoded_data)
             )
-            raise OffchainLookup(offchain_lookup_payload)
+            raise OffchainLookup(
+                offchain_lookup_payload, data=Web3.to_hex(parsed_data_as_bytes)
+            )
         raise e
 
 


Test patch:
diff --git a/web3/_utils/module_testing/eth_module.py b/web3/_utils/module_testing/eth_module.py
--- a/web3/_utils/module_testing/eth_module.py
+++ b/web3/_utils/module_testing/eth_module.py
@@ -11,6 +11,7 @@
     cast,
 )
 
+import eth_abi as abi
 from eth_typing import (
     BlockNumber,
     ChecksumAddress,
@@ -26,6 +27,7 @@
     is_list_like,
     is_same_address,
     is_string,
+    remove_0x_prefix,
     to_bytes,
 )
 from eth_utils.toolz import (
@@ -98,6 +100,8 @@
 )
 # "test offchain lookup" as an abi-encoded string
 OFFCHAIN_LOOKUP_TEST_DATA = "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001474657374206f6666636861696e206c6f6f6b7570000000000000000000000000"  # noqa: E501
+OFFCHAIN_LOOKUP_4BYTE_DATA = "0x556f1830"
+OFFCHAIN_LOOKUP_RETURN_DATA = "00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c0da96d05a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000002c68747470733a2f2f776562332e70792f676174657761792f7b73656e6465727d2f7b646174617d2e6a736f6e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002568747470733a2f2f776562332e70792f676174657761792f7b73656e6465727d2e6a736f6e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001474657374206f6666636861696e206c6f6f6b757000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001474657374206f6666636861696e206c6f6f6b7570000000000000000000000000"  # noqa: E501
 # "web3py" as an abi-encoded string
 WEB3PY_AS_HEXBYTES = "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000067765623370790000000000000000000000000000000000000000000000000000"  # noqa: E501
 
@@ -111,6 +115,21 @@
     )
 
 
+def abi_encoded_offchain_lookup_contract_address(
+    w3: Union["Web3", "AsyncWeb3"], offchain_lookup_contract: "Contract"
+) -> HexAddress:
+    return HexAddress(
+        remove_0x_prefix(
+            w3.to_hex(
+                abi.encode(
+                    ["address"],
+                    [to_bytes(hexstr=offchain_lookup_contract.address)],
+                )
+            )
+        )
+    )
+
+
 class AsyncEthModuleTest:
     @pytest.mark.asyncio
     async def test_eth_gas_price(self, async_w3: "AsyncWeb3") -> None:
@@ -949,16 +968,16 @@ async def test_eth_call_revert_with_msg(
         revert_contract: "Contract",
         unlocked_account: ChecksumAddress,
     ) -> None:
+        txn_params = revert_contract._prepare_transaction(
+            fn_name="revertWithMessage",
+            transaction={
+                "from": unlocked_account,
+                "to": revert_contract.address,
+            },
+        )
         with pytest.raises(
             ContractLogicError, match="execution reverted: Function has been reverted"
         ):
-            txn_params = revert_contract._prepare_transaction(
-                fn_name="revertWithMessage",
-                transaction={
-                    "from": unlocked_account,
-                    "to": revert_contract.address,
-                },
-            )
             await async_w3.eth.call(txn_params)
 
     @pytest.mark.asyncio
@@ -1048,27 +1067,35 @@ async def test_eth_call_offchain_lookup_raises_when_ccip_read_is_disabled(
         async_w3: "AsyncWeb3",
         async_offchain_lookup_contract: "Contract",
     ) -> None:
+        return_data = (
+            OFFCHAIN_LOOKUP_4BYTE_DATA
+            + abi_encoded_offchain_lookup_contract_address(
+                async_w3, async_offchain_lookup_contract
+            )
+            + OFFCHAIN_LOOKUP_RETURN_DATA
+        )
         # test AsyncContractCaller
-        with pytest.raises(OffchainLookup):
+        with pytest.raises(OffchainLookup) as e:
             await async_offchain_lookup_contract.caller(
                 ccip_read_enabled=False
-            ).testOffchainLookup(  # noqa: E501 type: ignore
-                OFFCHAIN_LOOKUP_TEST_DATA
-            )
+            ).testOffchainLookup(OFFCHAIN_LOOKUP_TEST_DATA)
+        assert e.value.data == return_data
 
         # test AsyncContractFunction call
-        with pytest.raises(OffchainLookup):
+        with pytest.raises(OffchainLookup) as excinfo:
             await async_offchain_lookup_contract.functions.testOffchainLookup(
                 OFFCHAIN_LOOKUP_TEST_DATA
             ).call(ccip_read_enabled=False)
+        assert excinfo.value.data == return_data
 
         # test global flag on the provider
         async_w3.provider.global_ccip_read_enabled = False
 
-        with pytest.raises(OffchainLookup):
+        with pytest.raises(OffchainLookup) as exc_info:
             await async_offchain_lookup_contract.functions.testOffchainLookup(  # noqa: E501 type: ignore
                 OFFCHAIN_LOOKUP_TEST_DATA
             ).call()
+        assert exc_info.value.data == return_data
 
         async_w3.provider.global_ccip_read_enabled = True  # cleanup
 
@@ -1093,7 +1120,6 @@ async def test_eth_call_offchain_lookup_call_flag_overrides_provider_flag(
         async_w3.provider.global_ccip_read_enabled = False
 
         response = await async_offchain_lookup_contract.functions.testOffchainLookup(
-            # noqa: E501 type: ignore
             OFFCHAIN_LOOKUP_TEST_DATA
         ).call(ccip_read_enabled=True)
         assert async_w3.codec.decode(["string"], response)[0] == "web3py"
@@ -3236,17 +3262,19 @@ def test_eth_call_revert_with_msg(
         revert_contract: "Contract",
         unlocked_account: ChecksumAddress,
     ) -> None:
+        txn_params = revert_contract._prepare_transaction(
+            fn_name="revertWithMessage",
+            transaction={
+                "from": unlocked_account,
+                "to": revert_contract.address,
+            },
+        )
+        data = "0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001b46756e6374696f6e20686173206265656e2072657665727465642e0000000000"  # noqa: E501
         with pytest.raises(
             ContractLogicError, match="execution reverted: Function has been reverted"
-        ):
-            txn_params = revert_contract._prepare_transaction(
-                fn_name="revertWithMessage",
-                transaction={
-                    "from": unlocked_account,
-                    "to": revert_contract.address,
-                },
-            )
+        ) as excinfo:
             w3.eth.call(txn_params)
+        assert excinfo.value.data == data
 
     def test_eth_call_revert_without_msg(
         self,
@@ -3280,8 +3308,9 @@ def test_eth_call_custom_error_revert_with_msg(
                 "to": revert_contract.address,
             },
         )
-        with pytest.raises(ContractCustomError, match=data):
+        with pytest.raises(ContractCustomError, match=data) as excinfo:
             w3.eth.call(txn_params)
+        assert excinfo.value.data == data
 
     def test_eth_call_custom_error_revert_without_msg(
         self,
@@ -3297,8 +3326,9 @@ def test_eth_call_custom_error_revert_without_msg(
                 "to": revert_contract.address,
             },
         )
-        with pytest.raises(ContractCustomError, match=data):
+        with pytest.raises(ContractCustomError, match=data) as excinfo:
             w3.eth.call(txn_params)
+        assert excinfo.value.data == data
 
     def test_eth_call_offchain_lookup(
         self,
@@ -3325,25 +3355,33 @@ def test_eth_call_offchain_lookup_raises_when_ccip_read_is_disabled(
         w3: "Web3",
         offchain_lookup_contract: "Contract",
     ) -> None:
+        return_data = (
+            OFFCHAIN_LOOKUP_4BYTE_DATA
+            + abi_encoded_offchain_lookup_contract_address(w3, offchain_lookup_contract)
+            + OFFCHAIN_LOOKUP_RETURN_DATA
+        )
         # test ContractFunction call
-        with pytest.raises(OffchainLookup):
+        with pytest.raises(OffchainLookup) as e:
             offchain_lookup_contract.functions.testOffchainLookup(
                 OFFCHAIN_LOOKUP_TEST_DATA
             ).call(ccip_read_enabled=False)
+        assert e.value.data == return_data
 
         # test ContractCaller call
-        with pytest.raises(OffchainLookup):
+        with pytest.raises(OffchainLookup) as excinfo:
             offchain_lookup_contract.caller(ccip_read_enabled=False).testOffchainLookup(
                 OFFCHAIN_LOOKUP_TEST_DATA
             )
+        assert excinfo.value.data == return_data
 
         # test global flag on the provider
         w3.provider.global_ccip_read_enabled = False
 
-        with pytest.raises(OffchainLookup):
+        with pytest.raises(OffchainLookup) as exc_info:
             offchain_lookup_contract.functions.testOffchainLookup(
                 OFFCHAIN_LOOKUP_TEST_DATA
             ).call()
+        assert exc_info.value.data == return_data
 
         w3.provider.global_ccip_read_enabled = True  # cleanup
 
@@ -3549,8 +3587,9 @@ def test_eth_estimate_gas_custom_error_revert_with_msg(
                 "to": revert_contract.address,
             },
         )
-        with pytest.raises(ContractCustomError, match=data):
+        with pytest.raises(ContractCustomError, match=data) as excinfo:
             w3.eth.estimate_gas(txn_params)
+        assert excinfo.value.data == data
 
     def test_eth_estimate_gas_custom_error_revert_without_msg(
         self,
@@ -3566,8 +3605,9 @@ def test_eth_estimate_gas_custom_error_revert_without_msg(
                 "to": revert_contract.address,
             },
         )
-        with pytest.raises(ContractCustomError, match=data):
+        with pytest.raises(ContractCustomError, match=data) as excinfo:
             w3.eth.estimate_gas(txn_params)
+        assert excinfo.value.data == data
 
     def test_eth_estimate_gas(
         self, w3: "Web3", unlocked_account_dual_type: ChecksumAddress