Repository: ethereum/web3.py

Issue:
Call contract function using block hash makes two requests inside
Hi!

All call to contract functions with block identifier as block hash makes two requests:
1. Fetch block by block hash.
2. Send call request to contract with block number from previous response.

https://github.com/ethereum/web3.py/blob/master/web3/contract/contract.py#L406

### How can it be fixed?

I propose to replace this part
https://github.com/ethereum/web3.py/blob/a35351d8bb3305a3b31bcbcac9f76a00c83c6d23/web3/_utils/contracts.py#L419-L421
with
```python
elif is_hex_encoded_block_hash(block_identifier):
    return block_identifier
elif isinstance(block_identifier, bytes):
    return '0x' + block_identifier.hex()
```




Discussion:
@fselmo the latest on `main` returns [`w3.eth.get_block(block_identifier)["number"]`](https://github.com/ethereum/web3.py/blob/main/web3/_utils/contracts.py#L425). I'm curious to know if this is still required?
Yeah the suggestion here seems to be a better one. Remove the call to `w3.eth.get_block()` altogether. I'm not sure this is actually breaking though. We could chalk it up to a `performance` newsfragment since it shouldn't alter what the return value is and just gets rid of an extra call. Thoughts there?
The return value should be the `block_number` rather than the `block_identifier`. The tests assert that the block number `0` is returned when passed the `HexStr` identifier. https://github.com/ethereum/web3.py/blob/main/tests/core/contracts/test_contract_util_functions.py#L103

I also found a `Contracts.parse_block_identifier_no_extra_call` function exists. I think it'd be good to just update `parse_block_identifier` and remove `parse_block_identifier_no_extra_call`. Is that possible, given this [comment](https://github.com/ethereum/web3.py/blob/main/web3/contract/async_contract.py#L587)?
> The return value should be the block_number rather than the block_identifier

You're right, I don't think `eth_call` accepts the block hash as the block identifier. Only the block number as an integer OR the `latest`, `earliest`, `pending`, etc keywords. I looked briefly and I'm pretty sure we can remove this entirely from the contract caller init - both in the sync and async cases - since we use it again in `ContractFunction.call()`.

That might be the answer.

@reedsa I'm pretty sure all of [this](https://github.com/ethereum/web3.py/blob/main/web3/contract/async_contract.py#L587-L590) can go and we can just pass `block_identifier` directly through in [line 595](https://github.com/ethereum/web3.py/blob/main/web3/contract/async_contract.py#L595) since that sets it up for [using self.call_function](https://github.com/ethereum/web3.py/blob/main/web3/contract/async_contract.py#L592) which returns `fn.call` (`AsyncFunction.call()`) which calls the correct method [here](https://github.com/ethereum/web3.py/blob/main/web3/contract/async_contract.py#L305). We should make sure there's a test in place to test that.

If the above is true, we can also remove the equivalent line from the `__init__` of the `ContractCaller` (non-async) [here](https://github.com/ethereum/web3.py/blob/main/web3/contract/contract.py#L591) since that will also get called [here](https://github.com/ethereum/web3.py/blob/main/web3/contract/contract.py#L305). We should also make sure there's an equivalent sync version of this same test.

Resolving patch:
diff --git a/web3/_utils/contracts.py b/web3/_utils/contracts.py
--- a/web3/_utils/contracts.py
+++ b/web3/_utils/contracts.py
@@ -438,23 +438,6 @@ def parse_block_identifier_int(w3: "Web3", block_identifier_int: int) -> BlockNu
     return BlockNumber(block_num)
 
 
-def parse_block_identifier_no_extra_call(
-    w3: Union["Web3", "AsyncWeb3"], block_identifier: BlockIdentifier
-) -> BlockIdentifier:
-    if block_identifier is None:
-        return w3.eth.default_block
-    elif isinstance(block_identifier, int) and block_identifier >= 0:
-        return block_identifier
-    elif block_identifier in ["latest", "earliest", "pending", "safe", "finalized"]:
-        return block_identifier
-    elif isinstance(block_identifier, bytes):
-        return HexBytes(block_identifier)
-    elif is_hex_encoded_block_hash(block_identifier):
-        return HexStr(str(block_identifier))
-    else:
-        raise BlockNumberOutofRange
-
-
 async def async_parse_block_identifier(
     async_w3: "AsyncWeb3", block_identifier: BlockIdentifier
 ) -> BlockIdentifier:
diff --git a/web3/contract/async_contract.py b/web3/contract/async_contract.py
--- a/web3/contract/async_contract.py
+++ b/web3/contract/async_contract.py
@@ -39,7 +39,6 @@
 )
 from web3._utils.contracts import (
     async_parse_block_identifier,
-    parse_block_identifier_no_extra_call,
 )
 from web3._utils.datatypes import (
     PropertyCheckingFactory,
@@ -584,15 +583,11 @@ def __init__(
                     decode_tuples=decode_tuples,
                 )
 
-                # TODO: The no_extra_call method gets around the fact that we can't call
-                #  the full async method from within a class's __init__ method. We need
-                #  to see if there's a way to account for all desired elif cases.
-                block_id = parse_block_identifier_no_extra_call(w3, block_identifier)
                 caller_method = partial(
                     self.call_function,
                     fn,
                     transaction=transaction,
-                    block_identifier=block_id,
+                    block_identifier=block_identifier,
                     ccip_read_enabled=ccip_read_enabled,
                 )
 
diff --git a/web3/contract/contract.py b/web3/contract/contract.py
--- a/web3/contract/contract.py
+++ b/web3/contract/contract.py
@@ -588,12 +588,11 @@ def __init__(
                     decode_tuples=decode_tuples,
                 )
 
-                block_id = parse_block_identifier(w3, block_identifier)
                 caller_method = partial(
                     self.call_function,
                     fn,
                     transaction=transaction,
-                    block_identifier=block_id,
+                    block_identifier=block_identifier,
                     ccip_read_enabled=ccip_read_enabled,
                 )
 


Test patch:
diff --git a/tests/core/contracts/test_contract_caller_interface.py b/tests/core/contracts/test_contract_caller_interface.py
--- a/tests/core/contracts/test_contract_caller_interface.py
+++ b/tests/core/contracts/test_contract_caller_interface.py
@@ -1,6 +1,7 @@
 import pytest
 
 from web3.exceptions import (
+    BlockNumberOutofRange,
     MismatchedABI,
     NoABIFound,
     NoABIFunctionsFound,
@@ -130,19 +131,56 @@ def test_caller_with_a_nonexistent_function(math_contract):
         contract.caller.thisFunctionDoesNotExist()
 
 
+def test_caller_with_invalid_block_identifier(w3, math_contract):
+    w3.provider.make_request(method="evm_mine", params=[5])
+    math_contract.functions.incrementCounter().transact()
+    math_contract.functions.incrementCounter().transact()
+
+    # Invalid `block_identifier` should not raise when passed to `caller` directly.
+    # The function call itself parses the value.
+    caller = math_contract.caller(block_identifier="abc")
+    with pytest.raises(BlockNumberOutofRange):
+        caller.counter()
+
+    # Calling the function with an invalid `block_identifier` will raise.
+    default_caller = math_contract.caller()
+    with pytest.raises(BlockNumberOutofRange):
+        default_caller.counter(block_identifier="abc")
+
+
 def test_caller_with_block_identifier(w3, math_contract):
-    start_num = w3.eth.get_block("latest").number
+    start = w3.eth.get_block("latest")
+    start_num = start.number
+    block_hash = start.hash
+    block_hex = w3.to_hex(block_hash)
+
     assert math_contract.caller.counter() == 0
 
     w3.provider.make_request(method="evm_mine", params=[5])
     math_contract.functions.incrementCounter().transact()
     math_contract.functions.incrementCounter().transact()
 
-    output1 = math_contract.caller(block_identifier=start_num + 6).counter()
-    output2 = math_contract.caller(block_identifier=start_num + 7).counter()
-
-    assert output1 == 1
-    assert output2 == 2
+    assert_ids = [
+        (block_hash, 0),
+        (block_hex, 0),
+        (start_num, 0),
+        (start_num + 6, 1),
+        (start_num + 7, 2),
+        ("latest", 2),
+        (None, 2),
+    ]
+
+    for block_identifier, expected_value in assert_ids:
+        assert (
+            math_contract.caller(block_identifier=block_identifier).counter()
+            == expected_value
+        )
+
+        # Function arg `block_identifier` produces same result
+        assert (
+            math_contract.caller().counter(block_identifier=block_identifier)
+            == expected_value
+        )
 
 
 def test_caller_with_block_identifier_and_transaction_dict(
@@ -298,21 +336,64 @@ async def test_async_caller_with_a_nonexistent_function(async_math_contract):
         await contract.caller.thisFunctionDoesNotExist()
 
 
+@pytest.mark.asyncio
+async def test_async_caller_with_invalid_block_identifier(
+    async_w3, async_math_contract
+):
+    await async_w3.provider.make_request(method="evm_mine", params=[5])
+    await async_math_contract.functions.incrementCounter().transact()
+    await async_math_contract.functions.incrementCounter().transact()
+
+    # Invalid `block_identifier` should not raise when passed to `caller` directly.
+    # The function call itself parses the value.
+    caller = async_math_contract.caller(block_identifier="abc")
+    with pytest.raises(BlockNumberOutofRange):
+        await caller.counter()
+
+    # Calling the function with an invalid `block_identifier` will raise.
+    default_caller = async_math_contract.caller()
+    with pytest.raises(BlockNumberOutofRange):
+        await default_caller.counter(block_identifier="abc")
+
+
 @pytest.mark.asyncio
 async def test_async_caller_with_block_identifier(async_w3, async_math_contract):
     start = await async_w3.eth.get_block("latest")
     start_num = start.number
+    block_hash = start.hash
+    block_hex = async_w3.to_hex(block_hash)
+
     assert await async_math_contract.caller.counter() == 0
 
     await async_w3.provider.make_request(method="evm_mine", params=[5])
     await async_math_contract.functions.incrementCounter().transact()
     await async_math_contract.functions.incrementCounter().transact()
 
-    output1 = await async_math_contract.caller(block_identifier=start_num + 6).counter()
-    output2 = await async_math_contract.caller(block_identifier=start_num + 7).counter()
-
-    assert output1 == 1
-    assert output2 == 2
+    assert_ids = [
+        (block_hash, 0),
+        (block_hex, 0),
+        (start_num, 0),
+        (start_num + 6, 1),
+        (start_num + 7, 2),
+        ("latest", 2),
+        (None, 2),
+    ]
+
+    for block_identifier, expected_value in assert_ids:
+        assert (
+            await async_math_contract.caller(
+                block_identifier=block_identifier
+            ).counter()
+            == expected_value
+        )
+
+        # Function arg `block_identifier` produces same result
+        assert (
+            await async_math_contract.caller().counter(
+                block_identifier=block_identifier
+            )
+            == expected_value
+        )
 
 
 @pytest.mark.asyncio