diff --git a/docs/contracts.rst b/docs/contracts.rst index 5c462073de..afcad36b34 100644 --- a/docs/contracts.rst +++ b/docs/contracts.rst @@ -743,6 +743,20 @@ Positional and keyword arguments supplied to the contract function subclass will be used to find the contract function by signature, and forwarded to the contract function when applicable. +`EIP-3668 `_ introduced support for the ``OffchainLookup`` revert / +CCIP Read support. The ``ccip_read_enabled`` flag is set to ``True`` for calls by default, as recommended in EIP-3668. +If raising the ``OffchainLookup`` revert is preferred, the flag may be set to ``False`` on a per-call basis. + +.. code-block:: python + + >>> myContract.functions.revertsWithOffchainLookup(myData).call(ccip_read_enabled=False) + *** web3.exceptions.OffchainLookup + +Disabling CCIP Read support can be useful if a transaction needs to be sent to the callback function. In such cases, +"preflighting" with an ``eth_call``, handling the ``OffchainLookup``, and sending the data via a transaction may be +necessary. See :ref:`ccip-read-example` in the examples section for how to preflight a transaction with a contract call. + + Methods ~~~~~~~~~~ diff --git a/docs/examples.rst b/docs/examples.rst index 0fa6373fc2..e8180bd026 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -624,6 +624,44 @@ When someone has an allowance they can transfer those tokens using the .. _ERC20: https://github.com/ethereum/EIPs/blob/7f4f0377730f5fc266824084188cc17cf246932e/EIPS/eip-20.md +.. _ccip-read-example: + +CCIP Read support for offchain lookup +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Contract calls support CCIP Read by default via a ``ccip_read_enabled`` flag that is set to a default value of ``True``. +The following should work by default without raising the ``OffchainLookup`` and instead handling it appropriately as +per the specification outlined in `EIP-3668 `_. + +.. code-block:: python + + myContract.functions.revertsWithOffchainLookup(myData).call() + +If the offchain lookup requires the user to send a transaction rather than make a call, this may be handled +appropriately in the following way: + +.. code-block:: python + + from web3 import Web3, WebsocketProvider + from web3.utils import handle_offchain_lookup + + w3 = Web3(WebsocketProvider(...)) + + myContract = w3.eth.contract(address=...) + myData = b'data for offchain lookup function call' + + # preflight with an `eth_call` and handle the exception + try: + myContract.functions.revertsWithOffchainLookup(myData).call(ccip_read_enabled=False) + except OffchainLookup as ocl: + tx = {'to': myContract.address, 'from': my_account} + data_for_callback_function = handle_offchain_lookup(ocl.payload) + tx['data'] = data_for_callback_function + + # send the built transaction with `eth_sendTransaction` or sign and send with `eth_sendRawTransaction` + tx_hash = w3.eth.send_transaction(tx) + + Contract Unit Tests in Python ----------------------------- diff --git a/docs/index.rst b/docs/index.rst index f3c59c70e9..57cc1d230b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -67,6 +67,7 @@ Table of Contents web3.miner web3.geth web3.parity + web3.utils gas_price ens constants diff --git a/docs/web3.eth.rst b/docs/web3.eth.rst index 9b271f182e..fcd837386b 100644 --- a/docs/web3.eth.rst +++ b/docs/web3.eth.rst @@ -1064,7 +1064,7 @@ The following methods are available on the ``web3.eth`` namespace. .. warning:: Deprecated: This property is deprecated in favor of :meth:`~web3.eth.Eth.sign_typed_data()` -.. py:method:: Eth.call(transaction, block_identifier=web3.eth.default_block, state_override=None) +.. py:method:: Eth.call(transaction, block_identifier=web3.eth.default_block, state_override=None, ccip_read_enabled=True) * Delegates to ``eth_call`` RPC Method @@ -1090,6 +1090,15 @@ The following methods are available on the ``web3.eth`` namespace. View their `usage documentation `_ for a list of possible parameters. + `EIP-3668 `_ introduced support for the ``OffchainLookup`` revert / CCIP + Read support. In order to properly handle a call to a contract function that reverts with an ``OffchainLookup`` + error for offchain data retrieval, the ``ccip_read_enabled`` flag has been added to the ``eth_call`` method. + ``ccip_read_enabled`` is set to ``True`` by default for calls, as recommended in EIP-3668. Therefore, calls to + contract functions that revert with an ``OffchainLookup`` will be handled appropriately by default. If the + ``ccip_read_enabled`` flag is set to ``False``, the call will raise the ``OffchainLookup`` instead of properly + handling the exception according to EIP-3668. This may be useful for "preflighting" a transaction call (see + :ref:`ccip-read-example` within the examples section). + .. py:method:: Eth.fee_history(block_count, newest_block, reward_percentiles=None) diff --git a/docs/web3.utils.rst b/docs/web3.utils.rst new file mode 100644 index 0000000000..afddaafd3b --- /dev/null +++ b/docs/web3.utils.rst @@ -0,0 +1,17 @@ +Utils +===== + +.. py:module:: web3.utils + +The ``utils`` module houses public utility and helper functions. + + +.. py:method:: Utils.handle_offchain_lookup(offchain_lookup_payload, transaction) + + Handle ``OffchainLookup`` reverts on contract function calls manually. For an example, see :ref:`ccip-read-example` + within the examples section. + + +.. py:method:: Utils.async_handle_offchain_lookup(offchain_lookup_payload, transaction) + + The async version of the ``handle_offchain_lookup()`` utility method described above. diff --git a/tests/core/contracts/test_offchain_lookup.py b/tests/core/contracts/test_offchain_lookup.py index ae458474be..36d2efae71 100644 --- a/tests/core/contracts/test_offchain_lookup.py +++ b/tests/core/contracts/test_offchain_lookup.py @@ -12,7 +12,7 @@ from web3._utils.module_testing.utils import ( mock_offchain_lookup_request_response, ) -from web3._utils.type_conversion_utils import ( +from web3._utils.type_conversion import ( to_hex_if_bytes, ) from web3.exceptions import ( diff --git a/tests/core/web3-module/test_providers.py b/tests/core/web3-module/test_providers.py index cc82b859ec..45b33e69e3 100644 --- a/tests/core/web3-module/test_providers.py +++ b/tests/core/web3-module/test_providers.py @@ -25,6 +25,5 @@ def test_auto_provider_none(): type(w3.provider) == AutoProvider -def test_provider_default_values_for_ccip_read(w3): - assert w3.provider.ccip_read_enabled +def test_provider_default_value_for_ccip_read_redirect(w3): assert w3.provider.ccip_read_max_redirects == 4 diff --git a/tests/ens/test_offchain_resolution.py b/tests/ens/test_offchain_resolution.py index 1ce305569d..4143a90dd1 100644 --- a/tests/ens/test_offchain_resolution.py +++ b/tests/ens/test_offchain_resolution.py @@ -1,11 +1,5 @@ import pytest -from eth_abi.abi import ( - decode_abi, -) -from eth_utils import ( - to_checksum_address, -) import requests from ens.utils import ( @@ -113,37 +107,19 @@ def mock_get(*args, **_): ens.address('offchainexample.eth') -def test_offchain_resolver_function_call_with_ccip_read_enabled(ens, monkeypatch): - def mock_get(*args, **kwargs): - return MockHttpSuccessResponse('get', *args, **kwargs) - - monkeypatch.setattr(requests.Session, 'get', mock_get) - +def test_offchain_resolver_function_call_raises_with_ccip_read_disabled(ens, monkeypatch): offchain_resolver = ens.resolver('offchainexample.eth') - # set global ccip_read_enabled flag on provider to False - ens.w3.provider.ccip_read_enabled = False - - # should fail here with global provider flag set to False + # should fail here with `ccip_read_enabled` flag set to False with pytest.raises(OffchainLookup): offchain_resolver.functions.resolve( ens_encode_name('offchainexample.eth'), ENCODED_ADDR_CALLDATA, - ).call() - - # pass flag on specific call should work - resolved_via_function_call = offchain_resolver.functions.resolve( - ens_encode_name('offchainexample.eth'), - ENCODED_ADDR_CALLDATA, - ).call(ccip_read_enabled=True) + ).call(ccip_read_enabled=False) # pass flag on specific call via ContractCaller is also an option - resolved_via_caller = offchain_resolver.caller(ccip_read_enabled=True).resolve( - ens_encode_name('offchainexample.eth'), - ENCODED_ADDR_CALLDATA, - ) - - assert resolved_via_caller == resolved_via_function_call - - decoded_result = decode_abi(['address'], resolved_via_caller)[0] - assert to_checksum_address(decoded_result) == EXPECTED_RESOLVED_ADDRESS + with pytest.raises(OffchainLookup): + offchain_resolver.caller(ccip_read_enabled=False).resolve( + ens_encode_name('offchainexample.eth'), + ENCODED_ADDR_CALLDATA, + ) diff --git a/web3/_utils/module_testing/eth_module.py b/web3/_utils/module_testing/eth_module.py index 8dbfdbcdbf..46d6d1b8de 100644 --- a/web3/_utils/module_testing/eth_module.py +++ b/web3/_utils/module_testing/eth_module.py @@ -46,7 +46,7 @@ mine_pending_block, mock_offchain_lookup_request_response, ) -from web3._utils.type_conversion_utils import ( +from web3._utils.type_conversion import ( to_hex_if_bytes, ) from web3.exceptions import ( @@ -55,6 +55,7 @@ InvalidAddress, InvalidTransaction, NameNotFound, + OffchainLookup, TimeExhausted, TooManyRequests, TransactionNotFound, @@ -81,6 +82,7 @@ ) UNKNOWN_ADDRESS = ChecksumAddress(HexAddress(HexStr('0xdEADBEeF00000000000000000000000000000000'))) + UNKNOWN_HASH = HexStr('0xdeadbeef00000000000000000000000000000000000000000000000000000000') # "test offchain lookup" as an abi-encoded string OFFCHAIN_LOOKUP_TEST_DATA = '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001474657374206f6666636861696e206c6f6f6b7570000000000000000000000000' # noqa: E501 @@ -772,12 +774,23 @@ async def test_eth_call_offchain_lookup( 'to': offchain_lookup_contract.address, 'data': '0x6337ed58000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001474657374206f6666636861696e206c6f6f6b7570000000000000000000000000' # noqa: E501 } - async_w3.provider.ccip_read_enabled = True response = await async_w3.eth.call(tx) # type: ignore response_as_bytes = async_w3.codec.decode_abi(['bytes'], response)[0] decoded_as_string = async_w3.codec.decode_abi(['string'], response_as_bytes)[0] assert decoded_as_string == 'web3py' + @pytest.mark.asyncio + async def test_eth_call_offchain_lookup_raises_when_ccip_read_is_disabled( + self, async_w3: "Web3", offchain_lookup_contract: "Contract", + ) -> None: + # TODO: change to contract call when async Contract is supported + tx = { + 'to': offchain_lookup_contract.address, + 'data': '0x6337ed58000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001474657374206f6666636861696e206c6f6f6b7570000000000000000000000000' # noqa: E501 + } + with pytest.raises(OffchainLookup): + await async_w3.eth.call(tx, ccip_read_enabled=False) # type: ignore + @pytest.mark.asyncio @pytest.mark.parametrize("max_redirects", range(-4, 4)) async def test_eth_call_offchain_lookup_raises_if_max_redirects_is_less_than_4( @@ -791,7 +804,6 @@ async def test_eth_call_offchain_lookup_raises_if_max_redirects_is_less_than_4( 'to': offchain_lookup_contract.address, 'data': '0x6337ed58000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001474657374206f6666636861696e206c6f6f6b7570000000000000000000000000' # noqa: E501 } - async_w3.provider.ccip_read_enabled = True async_w3.provider.ccip_read_max_redirects = max_redirects with pytest.raises(ValueError, match="at least 4"): await async_w3.eth.call(tx) # type: ignore @@ -818,7 +830,6 @@ async def test_eth_call_offchain_lookup_raises_for_improperly_formatted_rest_req 'to': offchain_lookup_contract.address, 'data': '0x6337ed58000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001474657374206f6666636861696e206c6f6f6b7570000000000000000000000000' # noqa: E501 } - async_w3.provider.ccip_read_enabled = True with pytest.raises(ValidationError, match="missing 'data' field"): await async_w3.eth.call(tx) # type: ignore @@ -858,7 +869,6 @@ async def test_eth_call_offchain_lookup_tries_next_url_for_non_4xx_error_status_ 'to': offchain_lookup_contract.address, 'data': '0x6337ed58000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001474657374206f6666636861696e206c6f6f6b7570000000000000000000000000' # noqa: E501 } - async_w3.provider.ccip_read_enabled = True response = await async_w3.eth.call(tx) # type: ignore response_as_bytes = async_w3.codec.decode_abi(['bytes'], response)[0] decoded_as_string = async_w3.codec.decode_abi(['string'], response_as_bytes)[0] @@ -887,7 +897,6 @@ async def test_eth_call_offchain_lookup_calls_raise_for_status_for_4xx_status_co 'to': offchain_lookup_contract.address, 'data': '0x6337ed58000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001474657374206f6666636861696e206c6f6f6b7570000000000000000000000000' # noqa: E501 } - async_w3.provider.ccip_read_enabled = True await async_w3.eth.call(tx) # type: ignore @pytest.mark.asyncio @@ -901,7 +910,6 @@ async def test_eth_call_offchain_lookup_raises_when_all_supplied_urls_fail( 'to': offchain_lookup_contract.address, 'data': '0x6337ed58000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001474657374206f6666636861696e206c6f6f6b7570000000000000000000000000' # noqa: E501 } - async_w3.provider.ccip_read_enabled = True await async_w3.eth.call(tx) # type: ignore @pytest.mark.asyncio @@ -920,7 +928,6 @@ async def test_eth_call_continuous_offchain_lookup_raises_with_too_many_requests with pytest.raises(TooManyRequests, match="Too many CCIP read redirects"): # TODO: change to contract call when async Contract is supported tx = {'to': offchain_lookup_contract.address, 'data': '0x09a3c01b'} - async_w3.provider.ccip_read_enabled = True await async_w3.eth.call(tx) # type: ignore @pytest.mark.asyncio @@ -2484,9 +2491,17 @@ def test_eth_call_offchain_lookup( ) response = offchain_lookup_contract.functions.testOffchainLookup( OFFCHAIN_LOOKUP_TEST_DATA - ).call(ccip_read_enabled=True) + ).call() assert w3.codec.decode_abi(['string'], response)[0] == 'web3py' + def test_eth_call_offchain_lookup_raises_when_ccip_read_is_disabled( + self, w3: "Web3", offchain_lookup_contract: "Contract", + ) -> None: + with pytest.raises(OffchainLookup): + offchain_lookup_contract.functions.testOffchainLookup( + OFFCHAIN_LOOKUP_TEST_DATA + ).call(ccip_read_enabled=False) + @pytest.mark.parametrize("max_redirects", range(-4, 4)) def test_eth_call_offchain_lookup_raises_if_max_redirects_is_less_than_4( self, @@ -2500,7 +2515,7 @@ def test_eth_call_offchain_lookup_raises_if_max_redirects_is_less_than_4( with pytest.raises(ValueError, match="at least 4"): offchain_lookup_contract.functions.testOffchainLookup( OFFCHAIN_LOOKUP_TEST_DATA - ).call(ccip_read_enabled=True) + ).call() w3.provider.ccip_read_max_redirects = default_max_redirects # cleanup @@ -2521,7 +2536,7 @@ def test_eth_call_offchain_lookup_raises_for_improperly_formatted_rest_request_r with pytest.raises(ValidationError, match="missing 'data' field"): offchain_lookup_contract.functions.testOffchainLookup( OFFCHAIN_LOOKUP_TEST_DATA - ).call(ccip_read_enabled=True) + ).call() @pytest.mark.parametrize('status_code_non_4xx_error', [100, 300, 500, 600]) def test_eth_call_offchain_lookup_tries_next_url_for_non_4xx_error_status_and_tests_POST( @@ -2555,7 +2570,7 @@ def test_eth_call_offchain_lookup_tries_next_url_for_non_4xx_error_status_and_te ) response = offchain_lookup_contract.functions.testOffchainLookup( OFFCHAIN_LOOKUP_TEST_DATA - ).call(ccip_read_enabled=True) + ).call() assert w3.codec.decode_abi(['string'], response)[0] == 'web3py' @pytest.mark.parametrize('status_code_4xx_error', [400, 410, 450, 499]) @@ -2577,7 +2592,7 @@ def test_eth_call_offchain_lookup_calls_raise_for_status_for_4xx_status_code( with pytest.raises(Exception, match="called raise_for_status\\(\\)"): offchain_lookup_contract.functions.testOffchainLookup( OFFCHAIN_LOOKUP_TEST_DATA - ).call(ccip_read_enabled=True) + ).call() def test_eth_call_offchain_lookup_raises_when_all_supplied_urls_fail( self, w3: "Web3", offchain_lookup_contract: "Contract", @@ -2586,7 +2601,7 @@ def test_eth_call_offchain_lookup_raises_when_all_supplied_urls_fail( with pytest.raises(Exception, match="Offchain lookup failed for supplied urls"): offchain_lookup_contract.functions.testOffchainLookup( OFFCHAIN_LOOKUP_TEST_DATA - ).call(ccip_read_enabled=True) + ).call() def test_eth_call_continuous_offchain_lookup_raises_with_too_many_requests( self, @@ -2601,7 +2616,7 @@ def test_eth_call_continuous_offchain_lookup_raises_with_too_many_requests( mocked_request_url=f'https://web3.py/gateway/{normalized_contract_address}/0x.json', ) with pytest.raises(TooManyRequests, match="Too many CCIP read redirects"): - offchain_lookup_contract.caller(ccip_read_enabled=True).continuousOffchainLookup() + offchain_lookup_contract.caller().continuousOffchainLookup() def test_eth_estimate_gas_revert_with_msg( self, diff --git a/web3/_utils/transactions.py b/web3/_utils/transactions.py index 3d1a694996..40acdf0780 100644 --- a/web3/_utils/transactions.py +++ b/web3/_utils/transactions.py @@ -1,18 +1,12 @@ import math from typing import ( TYPE_CHECKING, - Any, - Dict, List, Optional, cast, ) -from eth_abi import ( - encode_abi, -) from eth_typing import ( - URI, ChecksumAddress, ) from eth_utils.toolz import ( @@ -27,14 +21,6 @@ from web3._utils.compat import ( Literal, ) -from web3._utils.request import ( - get_response_from_get_request, - get_response_from_post_request, -) -from web3._utils.type_conversion_utils import ( - to_bytes_if_hex, - to_hex_if_bytes, -) from web3._utils.utility_methods import ( all_in_dict, any_in_dict, @@ -42,9 +28,6 @@ from web3.constants import ( DYNAMIC_FEE_TXN_PARAMS, ) -from web3.exceptions import ( - ValidationError, -) from web3.types import ( BlockIdentifier, TxData, @@ -256,67 +239,3 @@ def replace_transaction( w3, current_transaction, new_transaction ) return w3.eth.send_transaction(new_transaction) - - -def handle_offchain_lookup( - offchain_lookup_payload: Dict[str, Any], - transaction: TxParams, -) -> bytes: - formatted_sender = to_hex_if_bytes(offchain_lookup_payload['sender']).lower() - formatted_data = to_hex_if_bytes(offchain_lookup_payload['callData']).lower() - - if formatted_sender != to_hex_if_bytes(transaction['to']).lower(): - raise ValidationError( - 'Cannot handle OffchainLookup raised inside nested call. Returned `sender` ' - 'value does not equal `to` address in transaction.' - ) - - for url in offchain_lookup_payload['urls']: - formatted_url = URI( - str(url) - .replace('{sender}', str(formatted_sender)) - .replace('{data}', str(formatted_data)) - ) - - try: - if '{data}' in url and '{sender}' in url: - response = get_response_from_get_request(formatted_url) - elif '{sender}' in url: - response = get_response_from_post_request(formatted_url, data={ - "data": formatted_data, - "sender": formatted_sender - }) - else: - raise ValidationError('url not formatted properly.') - except Exception: - continue # try next url if timeout or issues making the request - - if 400 <= response.status_code <= 499: # if request returns 400 error, raise exception - response.raise_for_status() - if not 200 <= response.status_code <= 299: # if not 400 error, try next url - continue - - result = response.json() - - if 'data' not in result.keys(): - raise ValidationError( - "Improperly formatted response for offchain lookup HTTP request - missing 'data' " - "field." - ) - - encoded_data_with_function_selector = b''.join([ - # 4-byte callback function selector - to_bytes_if_hex(offchain_lookup_payload['callbackFunction']), - - # encode the `data` from the result and the `extraData` as bytes - encode_abi( - ['bytes', 'bytes'], - [ - to_bytes_if_hex(result['data']), - to_bytes_if_hex(offchain_lookup_payload['extraData']), - ] - ) - ]) - - return encoded_data_with_function_selector - raise Exception("Offchain lookup failed for supplied urls.") diff --git a/web3/_utils/type_conversion.py b/web3/_utils/type_conversion.py new file mode 100644 index 0000000000..3aefc1516f --- /dev/null +++ b/web3/_utils/type_conversion.py @@ -0,0 +1,27 @@ +from typing import ( + Union, +) + +from eth_typing import ( + HexStr, +) +from eth_utils import ( + to_bytes, + to_hex, +) + + +def to_hex_if_bytes(val: Union[HexStr, str, bytes, bytearray]) -> HexStr: + """ + Note: This method does not validate against all cases and is only meant to work with bytes and + hex strings. + """ + return to_hex(val) if isinstance(val, (bytes, bytearray)) else to_hex(hexstr=val) + + +def to_bytes_if_hex(val: Union[HexStr, str, bytes, bytearray]) -> bytes: + """ + Note: This method does not validate against all cases and is only meant to work with bytes and + hex strings. + """ + return to_bytes(hexstr=val) if isinstance(val, str) else val diff --git a/web3/_utils/type_conversion_utils.py b/web3/_utils/type_conversion_utils.py deleted file mode 100644 index 4c1aa5f65a..0000000000 --- a/web3/_utils/type_conversion_utils.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import ( - Union, -) - -from eth_typing import ( - HexStr, -) -from eth_utils import ( - to_bytes, - to_hex, -) - - -def to_hex_if_bytes(val: Union[str, bytes]) -> HexStr: - return to_hex(val) if isinstance(val, (bytes, bytearray)) else to_hex(hexstr=val) - - -def to_bytes_if_hex(val: Union[HexStr, str, bytes]) -> bytes: - return to_bytes(hexstr=val) if isinstance(val, str) else val diff --git a/web3/contract.py b/web3/contract.py index 1e23c2f4b8..b298531bfb 100644 --- a/web3/contract.py +++ b/web3/contract.py @@ -1247,10 +1247,13 @@ def __call__(self, clone._set_function_info() return clone - def call(self, transaction: Optional[TxParams] = None, - block_identifier: BlockIdentifier = 'latest', - state_override: Optional[CallOverrideParams] = None, - ) -> Any: + def call( + self, + transaction: Optional[TxParams] = None, + block_identifier: BlockIdentifier = 'latest', + state_override: Optional[CallOverride] = None, + ccip_read_enabled: bool = True, + ) -> Any: """ Execute a contract function call using the `eth_call` interface. @@ -1279,18 +1282,20 @@ def call(self, transaction: Optional[TxParams] = None, block_id = parse_block_identifier(self.w3, block_identifier) - return call_contract_function(self.w3, - self.address, - self._return_data_normalizers, - self.function_identifier, - call_transaction, - block_id, - self.contract_abi, - self.abi, - state_override, - *self.args, - **self.kwargs - ) + return call_contract_function( + self.w3, + self.address, + self._return_data_normalizers, + self.function_identifier, + call_transaction, + block_id, + self.contract_abi, + self.abi, + state_override, + ccip_read_enabled, + *self.args, + **self.kwargs + ) def transact(self, transaction: Optional[TxParams] = None) -> HexBytes: setup_transaction = self._transact(transaction) @@ -1358,7 +1363,8 @@ def __call__(self, async def call( self, transaction: Optional[TxParams] = None, block_identifier: BlockIdentifier = 'latest', - state_override: Optional[CallOverrideParams] = None, + state_override: Optional[CallOverride] = None, + ccip_read_enabled: bool = True, ) -> Any: """ Execute a contract function call using the `eth_call` interface. @@ -1807,7 +1813,7 @@ def __init__( address: ChecksumAddress, transaction: Optional[TxParams] = None, block_identifier: BlockIdentifier = 'latest', - ccip_read_enabled: bool = False, + ccip_read_enabled: bool = True, contract_function_class: Optional[Union[Type[ContractFunction], Type[AsyncContractFunction]]] = ContractFunction, # noqa: E501 ) -> None: self.w3 = w3 @@ -1880,20 +1886,30 @@ def call_function( class ContractCaller(BaseContractCaller): - def __init__(self, - abi: ABI, - w3: 'Web3', - address: ChecksumAddress, - transaction: Optional[TxParams] = None, - block_identifier: BlockIdentifier = 'latest') -> None: - super().__init__(abi, w3, address, - transaction, block_identifier, ContractFunction) + def __init__( + self, + abi: ABI, + w3: 'Web3', + address: ChecksumAddress, + transaction: Optional[TxParams] = None, + block_identifier: BlockIdentifier = 'latest', + ccip_read_enabled: bool = True, + ) -> None: + super().__init__( + abi, + w3, + address, + transaction, + block_identifier, + ccip_read_enabled, + ContractFunction, + ) def __call__( self, transaction: Optional[TxParams] = None, block_identifier: BlockIdentifier = 'latest', - ccip_read_enabled: bool = False, + ccip_read_enabled: bool = True, ) -> 'ContractCaller': if transaction is None: transaction = {} @@ -1909,20 +1925,30 @@ def __call__( class AsyncContractCaller(BaseContractCaller): - def __init__(self, - abi: ABI, - w3: 'Web3', - address: ChecksumAddress, - transaction: Optional[TxParams] = None, - block_identifier: BlockIdentifier = 'latest') -> None: - super().__init__(abi, w3, address, - transaction, block_identifier, AsyncContractFunction) + def __init__( + self, + abi: ABI, + w3: 'Web3', + address: ChecksumAddress, + transaction: Optional[TxParams] = None, + block_identifier: BlockIdentifier = 'latest', + ccip_read_enabled: bool = True, + ) -> None: + super().__init__( + abi, + w3, + address, + transaction, + block_identifier, + ccip_read_enabled, + AsyncContractFunction + ) def __call__( self, transaction: Optional[TxParams] = None, block_identifier: BlockIdentifier = 'latest', - ccip_read_enabled: bool = False, + ccip_read_enabled: bool = True, ) -> 'AsyncContractCaller': if transaction is None: transaction = {} @@ -1964,7 +1990,7 @@ def call_contract_function( contract_abi: Optional[ABI] = None, fn_abi: Optional[ABIFunction] = None, state_override: Optional[CallOverride] = None, - ccip_read_enabled: bool = False, + ccip_read_enabled: bool = True, *args: Any, **kwargs: Any) -> Any: """ @@ -1982,18 +2008,12 @@ def call_contract_function( fn_kwargs=kwargs, ) - if ccip_read_enabled: - return_data = w3.eth.durin_call( - call_transaction, - block_identifier=block_id, - state_override=state_override, - ) - else: - return_data = w3.eth.call( - call_transaction, - block_identifier=block_id, - state_override=state_override, - ) + return_data = w3.eth.call( + call_transaction, + block_identifier=block_id, + state_override=state_override, + ccip_read_enabled=ccip_read_enabled, + ) if fn_abi is None: fn_abi = find_matching_fn_abi(contract_abi, w3.codec, function_identifier, args, kwargs) @@ -2041,7 +2061,7 @@ async def async_call_contract_function( block_id: Optional[BlockIdentifier] = None, contract_abi: Optional[ABI] = None, fn_abi: Optional[ABIFunction] = None, - state_override: Optional[CallOverrideParams] = None, + state_override: Optional[CallOverride] = None, *args: Any, **kwargs: Any) -> Any: """ diff --git a/web3/eth.py b/web3/eth.py index b3bc29f53c..30a5bfd3ac 100644 --- a/web3/eth.py +++ b/web3/eth.py @@ -36,9 +36,6 @@ HexBytes, ) -from web3._utils.async_transactions import ( - async_handle_offchain_lookup, -) from web3._utils.blocks import ( select_method_for_block_identifier, ) @@ -66,7 +63,6 @@ assert_valid_transaction_params, extract_valid_transaction_params, get_required_transaction, - handle_offchain_lookup, replace_transaction, ) from web3.contract import ( @@ -113,6 +109,10 @@ Wei, _Hash32, ) +from web3.utils import ( + async_handle_offchain_lookup, + handle_offchain_lookup, +) class BaseEth(Module): @@ -413,14 +413,15 @@ async def call( transaction: TxParams, block_identifier: Optional[BlockIdentifier] = None, state_override: Optional[CallOverride] = None, + ccip_read_enabled: bool = True, ) -> Union[bytes, bytearray]: - if self.w3.provider.ccip_read_enabled: - # if ccip_read_enabled, handle OffchainLookup reverts appropriately via durin call - return await self.durin_call(transaction, block_identifier, state_override) + if ccip_read_enabled: + # if ccip read is enabled, handle OffchainLookup reverts via durin call + return await self._durin_call(transaction, block_identifier, state_override) return await self._call(transaction, block_identifier, state_override) - async def durin_call( + async def _durin_call( self, transaction: TxParams, block_identifier: Optional[BlockIdentifier] = None, @@ -820,14 +821,15 @@ def call( transaction: TxParams, block_identifier: Optional[BlockIdentifier] = None, state_override: Optional[CallOverride] = None, + ccip_read_enabled: bool = True, ) -> Union[bytes, bytearray]: - if self.w3.provider.ccip_read_enabled: - # if ccip_read_enabled, handle OffchainLookup reverts appropriately - self.durin_call(transaction, block_identifier, state_override) + if ccip_read_enabled: + # if ccip read is enabled, handle OffchainLookup reverts via durin call + self._durin_call(transaction, block_identifier, state_override) return self._call(transaction, block_identifier, state_override) - def durin_call( + def _durin_call( self, transaction: TxParams, block_identifier: Optional[BlockIdentifier] = None, diff --git a/web3/providers/base.py b/web3/providers/base.py index 8465892827..49f9545dd0 100644 --- a/web3/providers/base.py +++ b/web3/providers/base.py @@ -35,7 +35,6 @@ class BaseProvider: # a tuple of (all_middlewares, request_func) _request_func_cache: Tuple[Tuple[Middleware, ...], Callable[..., RPCResponse]] = (None, None) - ccip_read_enabled: bool = False ccip_read_max_redirects: int = 4 @property diff --git a/web3/utils/__init__.py b/web3/utils/__init__.py new file mode 100644 index 0000000000..66531ef5a3 --- /dev/null +++ b/web3/utils/__init__.py @@ -0,0 +1,6 @@ +from .async_exception_handling import ( # NOQA + async_handle_offchain_lookup, +) +from .exception_handling import ( # NOQA + handle_offchain_lookup, +) diff --git a/web3/utils/async_exception_handling.py b/web3/utils/async_exception_handling.py new file mode 100644 index 0000000000..259791e57b --- /dev/null +++ b/web3/utils/async_exception_handling.py @@ -0,0 +1,91 @@ +from typing import ( + Any, + Dict, +) + +from eth_abi import ( + encode_abi, +) +from eth_typing import ( + URI, +) + +from web3._utils.request import ( + async_get_json_from_client_response, + async_get_response_from_get_request, + async_get_response_from_post_request, +) +from web3._utils.type_conversion import ( + to_bytes_if_hex, + to_hex_if_bytes, +) +from web3.exceptions import ( + ValidationError, +) +from web3.types import ( + TxParams, +) + + +async def async_handle_offchain_lookup( + offchain_lookup_payload: Dict[str, Any], + transaction: TxParams, +) -> bytes: + formatted_sender = to_hex_if_bytes(offchain_lookup_payload['sender']).lower() + formatted_data = to_hex_if_bytes(offchain_lookup_payload['callData']).lower() + + if formatted_sender != to_hex_if_bytes(transaction['to']).lower(): + raise ValidationError( + 'Cannot handle OffchainLookup raised inside nested call. Returned `sender` value does ' + 'not equal `to` address in transaction.' + ) + + for url in offchain_lookup_payload['urls']: + formatted_url = URI( + str(url) + .replace('{sender}', str(formatted_sender)) + .replace('{data}', str(formatted_data)) + ) + + try: + if '{data}' in url and '{sender}' in url: + response = await async_get_response_from_get_request(formatted_url) + elif '{sender}' in url: + response = await async_get_response_from_post_request(formatted_url, data={ + "data": formatted_data, + "sender": formatted_sender + }) + else: + raise ValidationError('url not formatted properly.') + except Exception: + continue # try next url if timeout or issues making the request + + if 400 <= response.status <= 499: # if request returns 400 error, raise exception + response.raise_for_status() + if not 200 <= response.status <= 299: # if not 400 error, try next url + continue + + result = await async_get_json_from_client_response(response) + + if 'data' not in result.keys(): + raise ValidationError( + "Improperly formatted response for offchain lookup HTTP request - missing 'data' " + "field." + ) + + encoded_data_with_function_selector = b''.join([ + # 4-byte callback function selector + to_bytes_if_hex(offchain_lookup_payload['callbackFunction']), + + # encode the `data` from the result and the `extraData` as bytes + encode_abi( + ['bytes', 'bytes'], + [ + to_bytes_if_hex(result['data']), + to_bytes_if_hex(offchain_lookup_payload['extraData']), + ] + ) + ]) + + return encoded_data_with_function_selector + raise Exception("Offchain lookup failed for supplied urls.") diff --git a/web3/utils/exception_handling.py b/web3/utils/exception_handling.py new file mode 100644 index 0000000000..4417920069 --- /dev/null +++ b/web3/utils/exception_handling.py @@ -0,0 +1,90 @@ +from typing import ( + Any, + Dict, +) + +from eth_abi import ( + encode_abi, +) +from eth_typing import ( + URI, +) + +from web3._utils.request import ( + get_response_from_get_request, + get_response_from_post_request, +) +from web3._utils.type_conversion import ( + to_bytes_if_hex, + to_hex_if_bytes, +) +from web3.exceptions import ( + ValidationError, +) +from web3.types import ( + TxParams, +) + + +def handle_offchain_lookup( + offchain_lookup_payload: Dict[str, Any], + transaction: TxParams, +) -> bytes: + formatted_sender = to_hex_if_bytes(offchain_lookup_payload['sender']).lower() + formatted_data = to_hex_if_bytes(offchain_lookup_payload['callData']).lower() + + if formatted_sender != to_hex_if_bytes(transaction['to']).lower(): + raise ValidationError( + 'Cannot handle OffchainLookup raised inside nested call. Returned `sender` value does ' + 'not equal `to` address in transaction.' + ) + + for url in offchain_lookup_payload['urls']: + formatted_url = URI( + str(url) + .replace('{sender}', str(formatted_sender)) + .replace('{data}', str(formatted_data)) + ) + + try: + if '{data}' in url and '{sender}' in url: + response = get_response_from_get_request(formatted_url) + elif '{sender}' in url: + response = get_response_from_post_request(formatted_url, data={ + "data": formatted_data, + "sender": formatted_sender, + }) + else: + raise ValidationError('url not formatted properly.') + except Exception: + continue # try next url if timeout or issues making the request + + if 400 <= response.status_code <= 499: # if request returns 400 error, raise exception + response.raise_for_status() + if not 200 <= response.status_code <= 299: # if not 400 error, try next url + continue + + result = response.json() + + if 'data' not in result.keys(): + raise ValidationError( + "Improperly formatted response for offchain lookup HTTP request - missing 'data' " + "field." + ) + + encoded_data_with_function_selector = b''.join([ + # 4-byte callback function selector + to_bytes_if_hex(offchain_lookup_payload['callbackFunction']), + + # encode the `data` from the result and the `extraData` as bytes + encode_abi( + ['bytes', 'bytes'], + [ + to_bytes_if_hex(result['data']), + to_bytes_if_hex(offchain_lookup_payload['extraData']), + ] + ) + ]) + + return encoded_data_with_function_selector + raise Exception("Offchain lookup failed for supplied urls.")