Repository: ethereum/web3.py

Issue:
web3.middlewares.simple_cache_middleware only works correctly for a single web3 instance
* Version: 6.3.0
* Python: 3.10.1
* OS: osx

<details><summary>`pip freeze` output</summary>
<p>

```
aiohttp==3.8.4
aiosignal==1.3.1
alabaster==0.7.13
async-timeout==4.0.2
attrs==23.1.0
Babel==2.12.1
base58==2.1.1
bitarray==2.7.4
black==23.3.0
bleach==6.0.0
build==0.10.0
bump2version==1.0.1
bumpversion==0.6.0
cached-property==1.5.2
certifi==2023.5.7
charset-normalizer==3.1.0
click==8.1.3
click-default-group==1.2.2
colorama==0.4.6
cytoolz==0.12.1
distlib==0.3.6
docopt==0.6.2
docutils==0.18.1
eth-abi==4.0.0
eth-account==0.8.0
eth-bloom==2.0.0
eth-hash==0.5.1
eth-keyfile==0.6.1
eth-keys==0.4.0
eth-rlp==0.3.0
eth-tester==0.9.0b1
eth-typing==3.3.0
eth-utils==2.1.0
exceptiongroup==1.1.1
execnet==1.9.0
filelock==3.12.0
flake8==3.8.3
flaky==3.7.0
frozenlist==1.3.3
hexbytes==0.3.0
hypothesis==6.75.9
idna==3.4
imagesize==1.4.1
importlib-metadata==6.6.0
incremental==22.10.0
iniconfig==2.0.0
ipfshttpclient==0.8.0a2
isort==5.12.0
jaraco.classes==3.2.3
Jinja2==3.1.2
jsonschema==4.17.3
keyring==23.13.1
lru-dict==1.2.0
markdown-it-py==2.2.0
MarkupSafe==2.1.2
mccabe==0.6.1
mdurl==0.1.2
more-itertools==9.1.0
multiaddr==0.0.9
multidict==6.0.4
mypy==0.910
mypy-extensions==0.4.4
netaddr==0.8.0
packaging==23.1
parsimonious==0.9.0
pathspec==0.11.1
pkginfo==1.9.6
platformdirs==3.5.1
pluggy==0.13.1
protobuf==4.23.2
py==1.11.0
py-ecc==6.0.0
py-evm==0.7.0a2
py-geth==3.12.0
pycodestyle==2.6.0
pycryptodome==3.18.0
pyethash==0.1.27
pyflakes==2.2.0
Pygments==2.15.1
pyproject_hooks==1.0.0
pyrsistent==0.19.3
pytest==7.3.1
pytest-asyncio==0.21.0
pytest-mock==3.10.0
pytest-watch==4.2.0
pytest-xdist==3.3.1
readme-renderer==37.3
regex==2023.5.5
requests==2.31.0
requests-toolbelt==1.0.0
rfc3986==2.0.0
rich==13.4.1
rlp==3.0.0
safe-pysha3==1.0.4
semantic-version==2.10.0
six==1.16.0
snowballstemmer==2.2.0
sortedcontainers==2.4.0
Sphinx==6.2.1
sphinx-rtd-theme==1.2.1
sphinxcontrib-applehelp==1.0.4
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.1
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
toml==0.10.2
tomli==2.0.1
toolz==0.12.0
towncrier==21.9.0
tox==3.28.0
tqdm==4.65.0
trie==2.1.0
twine==4.0.2
types-protobuf==3.19.13
types-requests==2.31.0.1
types-setuptools==67.8.0.0
types-urllib3==1.26.25.13
typing_extensions==4.6.3
urllib3==2.0.2
varint==1.0.2
virtualenv==20.23.0
watchdog==3.0.0
-e git+ssh://git@github.com/ethereum/web3.py.git@1782f7d033aeb0aa2941b1d5fad950ddbec5b36e#egg=web3
webencodings==0.5.1
websockets==11.0.3
when-changed==0.3.0
yarl==1.9.2
zipp==3.15.0
```

</p>
</details> 




### What was wrong?

In the `nucypher` client, we're currently working with a multi-chain setting, so each node is connected to both ETH and Polygon (and in the future to other EVM compatible chains). We recently noticed that when calling `w3.eth.chain_id` in these web3 instances, we get mixed up results: sometimes both web3 instances return 137 (polygon), sometimes 1 (ethereum mainnet). 

After @kprasch and I pulled our hair out for a few days, we realized the problem is that in both providers we add the available `web3.middlewares.simple_cache_middleware`, which by defaults caches the result for `eth_chainId`. We didn't expect this cache to share state between different web3 instances.

A simple test reproducing the problem can be seen here: https://github.com/cygnusv/web3.py/blob/6c35544e3c50cb42f528f27b7268dbe8a5a17f87/tests/core/middleware/test_simple_cache_middleware.py#L161-L178

Now, the question is: _Is the `web3.middlewares.simple_cache_middleware` object expected to be safe to be shared between different web3 instances?_
- If so, then cache keys should also include the web3 instance (e.g., `id(w3)`)
- Otherwise, it should be clear from the documentation to not reuse that cache middleware object and to construct individual middlewares for each web3 instance.

Additionally, from code inspection it seems that `time_based_cache_middleware` has the same problem (but haven't written a test for that). Since `latest_block_based_cache_middleware` includes the `latest_block_hash` as part of the cache key is extremely unlikely that it would be a problem, but potentially it can happen.

### How can it be fixed?

We have a suggested fix for the above assuming you can use the simple cache middleware with different instances. See this branch here: https://github.com/ethereum/web3.py/compare/main...cygnusv:web3.py:middlewares. Would be happy to open a PR for this.



Discussion:

Resolving patch:
diff --git a/web3/middleware/async_cache.py b/web3/middleware/async_cache.py
--- a/web3/middleware/async_cache.py
+++ b/web3/middleware/async_cache.py
@@ -56,27 +56,33 @@ async def async_construct_simple_cache_middleware(
         ``response`` and returns a boolean as to whether the response should be
         cached.
     """
-    if cache is None:
-        cache = SimpleCache(256)
 
     async def async_simple_cache_middleware(
         make_request: Callable[[RPCEndpoint, Any], Any], _async_w3: "AsyncWeb3"
     ) -> AsyncMiddlewareCoroutine:
         lock = threading.Lock()
 
+        # It's not imperative that we define ``_cache`` here rather than in
+        # ``async_construct_simple_cache_middleware``. Due to the nature of async,
+        # construction is awaited and doesn't happen at import. This means separate
+        # instances would still get unique caches. However, to keep the code consistent
+        # with the synchronous version, and provide less ambiguity, we define it
+        # similarly to the synchronous version here.
+        _cache = cache if cache else SimpleCache(256)
+
         async def middleware(method: RPCEndpoint, params: Any) -> RPCResponse:
             if method in rpc_whitelist:
                 cache_key = generate_cache_key(
                     f"{threading.get_ident()}:{(method, params)}"
                 )
-                cached_request = cache.get_cache_entry(cache_key)
+                cached_request = _cache.get_cache_entry(cache_key)
                 if cached_request is not None:
                     return cached_request
 
                 response = await make_request(method, params)
                 if should_cache_fn(method, params, response):
                     async with async_lock(_async_request_thread_pool, lock):
-                        cache.cache(cache_key, response)
+                        _cache.cache(cache_key, response)
                 return response
             else:
                 return await make_request(method, params)
diff --git a/web3/middleware/cache.py b/web3/middleware/cache.py
--- a/web3/middleware/cache.py
+++ b/web3/middleware/cache.py
@@ -81,9 +81,6 @@ def construct_simple_cache_middleware(
         ``response`` and returns a boolean as to whether the response should be
         cached.
     """
-    if cache is None:
-        cache = SimpleCache(256)
-
     if rpc_whitelist is None:
         rpc_whitelist = SIMPLE_CACHE_RPC_WHITELIST
 
@@ -92,12 +89,17 @@ def simple_cache_middleware(
     ) -> Callable[[RPCEndpoint, Any], RPCResponse]:
         lock = threading.Lock()
 
+        # Setting the cache here, rather than in ``construct_simple_cache_middleware``,
+        # ensures that each instance of the middleware has its own cache. This is
+        # important for compatibility with multiple ``Web3`` instances.
+        _cache = cache if cache else SimpleCache(256)
+
         def middleware(method: RPCEndpoint, params: Any) -> RPCResponse:
             if method in rpc_whitelist:
                 cache_key = generate_cache_key(
                     f"{threading.get_ident()}:{(method, params)}"
                 )
-                cached_request = cache.get_cache_entry(cache_key)
+                cached_request = _cache.get_cache_entry(cache_key)
                 if cached_request is not None:
                     return cached_request
 
@@ -105,7 +107,7 @@ def middleware(method: RPCEndpoint, params: Any) -> RPCResponse:
                 if should_cache_fn(method, params, response):
                     if lock.acquire(blocking=False):
                         try:
-                            cache.cache(cache_key, response)
+                            _cache.cache(cache_key, response)
                         finally:
                             lock.release()
                 return response


Test patch:
diff --git a/tests/core/middleware/test_simple_cache_middleware.py b/tests/core/middleware/test_simple_cache_middleware.py
--- a/tests/core/middleware/test_simple_cache_middleware.py
+++ b/tests/core/middleware/test_simple_cache_middleware.py
@@ -11,9 +11,11 @@
     generate_cache_key,
 )
 from web3.middleware import (
+    async_simple_cache_middleware,
     construct_error_generator_middleware,
     construct_result_generator_middleware,
     construct_simple_cache_middleware,
+    simple_cache_middleware,
 )
 from web3.middleware.async_cache import (
     async_construct_simple_cache_middleware,
@@ -155,6 +157,40 @@ def test_simple_cache_middleware_does_not_cache_endpoints_not_in_whitelist(w3):
     assert result_a != result_b
 
 
+def test_simple_cache_middleware_does_not_share_state_between_providers():
+    result_generator_a = construct_result_generator_middleware(
+        {RPCEndpoint("eth_chainId"): lambda *_: 11111}
+    )
+    result_generator_b = construct_result_generator_middleware(
+        {RPCEndpoint("eth_chainId"): lambda *_: 22222}
+    )
+    result_generator_c = construct_result_generator_middleware(
+        {RPCEndpoint("eth_chainId"): lambda *_: 33333}
+    )
+
+    w3_a = Web3(provider=BaseProvider(), middlewares=[result_generator_a])
+    w3_b = Web3(provider=BaseProvider(), middlewares=[result_generator_b])
+    w3_c = Web3(  # instantiate the Web3 instance with the cache middleware
+        provider=BaseProvider(),
+        middlewares=[
+            result_generator_c,
+            simple_cache_middleware,
+        ],
+    )
+
+    w3_a.middleware_onion.add(simple_cache_middleware)
+    w3_b.middleware_onion.add(simple_cache_middleware)
+
+    result_a = w3_a.manager.request_blocking("eth_chainId", [])
+    result_b = w3_b.manager.request_blocking("eth_chainId", [])
+    result_c = w3_c.manager.request_blocking("eth_chainId", [])
+
+    assert result_a != result_b != result_c
+    assert result_a == 11111
+    assert result_b == 22222
+    assert result_c == 33333
+
+
 # -- async -- #
 
 
@@ -275,3 +311,42 @@ async def test_async_simple_cache_middleware_does_not_cache_non_whitelist_endpoi
     result_b = await async_w3.manager.coro_request("not_whitelisted", [])
 
     assert result_a != result_b
+
+
+@pytest.mark.asyncio
+async def test_async_simple_cache_middleware_does_not_share_state_between_providers():
+    result_generator_a = await async_construct_result_generator_middleware(
+        {RPCEndpoint("eth_chainId"): lambda *_: 11111}
+    )
+    result_generator_b = await async_construct_result_generator_middleware(
+        {RPCEndpoint("eth_chainId"): lambda *_: 22222}
+    )
+    result_generator_c = await async_construct_result_generator_middleware(
+        {RPCEndpoint("eth_chainId"): lambda *_: 33333}
+    )
+
+    w3_a = AsyncWeb3(
+        provider=AsyncEthereumTesterProvider(), middlewares=[result_generator_a]
+    )
+    w3_b = AsyncWeb3(
+        provider=AsyncEthereumTesterProvider(), middlewares=[result_generator_b]
+    )
+    w3_c = AsyncWeb3(  # instantiate the Web3 instance with the cache middleware
+        provider=AsyncEthereumTesterProvider(),
+        middlewares=[
+            result_generator_c,
+            async_simple_cache_middleware,
+        ],
+    )
+
+    w3_a.middleware_onion.add(async_simple_cache_middleware)
+    w3_b.middleware_onion.add(async_simple_cache_middleware)
+
+    result_a = await w3_a.eth.chain_id
+    result_b = await w3_b.eth.chain_id
+    result_c = await w3_c.eth.chain_id
+
+    assert result_a != result_b != result_c
+    assert result_a == 11111
+    assert result_b == 22222
+    assert result_c == 33333