Repository: ethereum/web3.py
Pull request #3578 (2025-01-15 17:57:13)

Add event topic and fix sub manager docs
### What was wrong?

- The code block was not correct for subscription manager docs.
- Getting the contract event's topic was much too difficult.

### How was it fixed?

- Fix subscription manager docs.
- Add `topic` property to `BaseContractEvent`.
- Add topic tests.

### Todo:

- [x] Clean up commit history
- [x] Add or update documentation related to these changes
- [x] Add entry to the [release notes](https://github.com/ethereum/web3.py/blob/main/newsfragments/README.md)

#### Cute Animal Picture

<img width="724" alt="Screenshot 2025-01-15 at 11 40 56" src="https://github.com/user-attachments/assets/10b3de8d-6def-4540-83c7-4a6a697d5321" />


fselmo: > Edit: LGTM once you figure out the failing test :)

It looked related to multiple contract init time test. Glad we have that in there. In order to not add to init times, I opted for calculating the topic as an `@property` and setting it only when it is called ([latest commit](https://github.com/ethereum/web3.py/pull/3578/commits/6afe5e1aa474f2f7fbfaaf6c380fe2c9a5438eb0))

Patch:
diff --git a/docs/internals.rst b/docs/internals.rst
index bae72f4c..605afbf3 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -518,12 +518,18 @@ when it comes in.
     ...     log_receipt = handler_context.result
     ...     # event is now available in the handler context, because we pass it to in the
     ...     # ``handler_context`` when subscribing to the log
-    ...     event_data = handler_context.event.process_log(log_receipt)
+    ...     event_data = handler_context.transfer_event.process_log(log_receipt)
     ...     print(f"Log event data: {event_data}\n")
 
     >>> async def sub_manager():
-    ...     local_w3 = await AsyncWeb3(AsyncIPCProvider(LOCAL_IPC, label="mainnet-ipc"))
+    ...     local_w3 = await AsyncWeb3(AsyncIPCProvider(LOCAL_IPC))
+    ...
     ...     # subscribe to many subscriptions via the subscription manager with handlers
+    ...     weth_contract = local_w3.eth.contract(
+    ...         address=local_w3.to_checksum_address("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
+    ...         abi=WETH_ABI,
+    ...     )
+    ...     transfer_event = weth_contract.events.Transfer()
     ...     await local_w3.subscription_manager.subscribe(
     ...         [
     ...             NewHeadsSubscription(label="new-heads-mainnet", handler=new_heads_handler),
@@ -534,16 +540,16 @@ when it comes in.
     ...             ),
     ...             LogsSubscription(
     ...                 label="WETH transfers",  # optional label
-    ...                 address=local_w3.to_checksum_address("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
-    ...                 topics=[HexStr("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")],
+    ...                 address=weth_contract.address,
+    ...                 topics=[transfer_event.topic],
     ...                 handler=log_handler,
     ...                 # optional ``handler_context`` args to help parse a response
-    ...                 handler_context={"event": my_event},
+    ...                 handler_context={"transfer_event": transfer_event},
     ...             ),
     ...         ]
     ...     )
     ...
-    ...     public_w3 = await AsyncWeb3(WebSocketProvider(PUBLIC_PROVIDER_WS, label="public-ws"))
+    ...     public_w3 = await AsyncWeb3(WebSocketProvider(PUBLIC_PROVIDER_WS))
     ...     # subscribe via eth_subscribe, with handler and label (optional)
     ...     await public_w3.eth.subscribe("public_newHeads", handler=pending_tx_handler, label="new-heads-public-ws")
 
diff --git a/newsfragments/3578.feature.rst b/newsfragments/3578.feature.rst
new file mode 100644
index 00000000..4c4396d6
--- /dev/null
+++ b/newsfragments/3578.feature.rst
@@ -0,0 +1,1 @@
+Implement a ``topic`` property for contract events to retrieve the topic for the event.
diff --git a/web3/contract/base_contract.py b/web3/contract/base_contract.py
index ef3605e5..31fcdcfc 100644
--- a/web3/contract/base_contract.py
+++ b/web3/contract/base_contract.py
@@ -165,6 +165,7 @@ class BaseContractEvent:
     argument_types: Tuple[str, ...] = tuple()
     args: Any = None
     kwargs: Any = None
+    _topic: HexStr = None
 
     def __init__(self, *argument_names: str, abi: Optional[ABIEvent] = None) -> None:
         self.abi_element_identifier = type(self).__name__
@@ -188,6 +189,12 @@ class BaseContractEvent:
             return f"<Event {abi_to_signature(self.abi)}>"
         return f"<Event {get_abi_element_signature(self.abi_element_identifier)}>"
 
+    @property
+    def topic(self) -> HexStr:
+        if self._topic is None:
+            self._topic = encode_hex(keccak(text=self.signature))
+        return self._topic
+
     @combomethod
     def _get_event_abi(cls) -> ABIEvent:
         if cls.abi:


Test patch:
diff --git a/tests/core/contracts/conftest.py b/tests/core/contracts/conftest.py
index 31eb99f8..144e0e6f 100644
--- a/tests/core/contracts/conftest.py
+++ b/tests/core/contracts/conftest.py
@@ -803,6 +803,26 @@ async def async_nested_tuple_contract_with_decode_tuples(
     )
 
 
+@pytest_asyncio.fixture
+async def async_event_contract(
+    async_w3, async_wait_for_transaction, async_wait_for_block, address_conversion_func
+):
+    async_event_contract_factory = async_w3.eth.contract(**EVENT_CONTRACT_DATA)
+
+    await async_wait_for_block(async_w3)
+    deploy_txn_hash = await async_event_contract_factory.constructor().transact(
+        {"gas": 1000000}
+    )
+    deploy_receipt = await async_wait_for_transaction(async_w3, deploy_txn_hash)
+    contract_address = address_conversion_func(deploy_receipt["contractAddress"])
+
+    bytecode = await async_w3.eth.get_code(contract_address)
+    assert bytecode == async_event_contract_factory.bytecode_runtime
+    event_contract = async_event_contract_factory(address=contract_address)
+    assert event_contract.address == contract_address
+    return event_contract
+
+
 @pytest_asyncio.fixture
 async def async_ambiguous_event_contract(async_w3, address_conversion_func):
     async_ambiguous_event_contract_factory = async_w3.eth.contract(
diff --git a/tests/core/contracts/test_contract_events.py b/tests/core/contracts/test_contract_events.py
index 8381d7b4..81dc3d42 100644
--- a/tests/core/contracts/test_contract_events.py
+++ b/tests/core/contracts/test_contract_events.py
@@ -8,6 +8,9 @@ from typing import (
 from eth_typing import (
     ABIEvent,
 )
+from eth_utils import (
+    encode_hex,
+)
 from eth_utils.toolz import (
     compose,
     curry,
@@ -136,6 +139,95 @@ def test_events_iterator(math_contract):
         assert event.name == expected_event.name
 
 
+def test_contract_event_topic(event_contract):
+    event = event_contract.events.LogSingleArg()
+    assert event.topic == encode_hex(event_contract.w3.keccak(text=event.signature))
+
+
+def test_contract_event_topic_no_parens(event_contract):
+    event = event_contract.events.LogSingleArg
+    assert event.topic == encode_hex(event_contract.w3.keccak(text=event.signature))
+
+
+@pytest.mark.parametrize(
+    "event_abi,expected_signature",
+    (
+        (
+            {
+                "name": "Transfer",
+                "type": "event",
+                "inputs": [
+                    {"type": "address", "name": "from", "indexed": True},
+                    {"type": "address", "name": "to", "indexed": True},
+                    {"type": "uint256", "name": "value", "indexed": False},
+                ],
+            },
+            "Transfer(address,address,uint256)",
+        ),
+        (
+            {
+                "name": "TransferBatch",
+                "type": "event",
+                "inputs": [
+                    {"type": "address", "name": "operator", "indexed": True},
+                    {"type": "address", "name": "from", "indexed": True},
+                    {"type": "address", "name": "to", "indexed": True},
+                    {"type": "uint256[]", "name": "ids", "indexed": False},
+                    {"type": "uint256[]", "name": "values", "indexed": False},
+                ],
+            },
+            "TransferBatch(address,address,address,uint256[],uint256[])",
+        ),
+        (
+            {
+                "name": "Approval",
+                "type": "event",
+                "inputs": [
+                    {"type": "address", "name": "owner", "indexed": True},
+                    {"type": "address", "name": "approved", "indexed": True},
+                    {"type": "uint256", "name": "tokenId", "indexed": True},
+                ],
+            },
+            "Approval(address,address,uint256)",
+        ),
+        (
+            {
+                "name": "ApprovalForAll",
+                "type": "event",
+                "inputs": [
+                    {"type": "address", "name": "owner", "indexed": True},
+                    {"type": "address", "name": "operator", "indexed": True},
+                    {"type": "bool", "name": "approved", "indexed": False},
+                ],
+            },
+            "ApprovalForAll(address,address,bool)",
+        ),
+        (
+            {
+                "name": "OwnershipTransferred",
+                "type": "event",
+                "inputs": [
+                    {"type": "address", "name": "previousOwner", "indexed": True},
+                    {"type": "address", "name": "newOwner", "indexed": True},
+                ],
+            },
+            "OwnershipTransferred(address,address)",
+        ),
+    ),
+)
+def test_contract_event_signatures_and_topics(w3, event_abi, expected_signature):
+    event = ContractEvent(abi=event_abi)
+    assert event.signature == expected_signature
+    assert event.topic == encode_hex(w3.keccak(text=expected_signature))
+
+    async_event = AsyncContractEvent(abi=event_abi)
+    assert async_event.signature == expected_signature
+    assert async_event.topic == encode_hex(w3.keccak(text=expected_signature))
+
+
+# -- async -- #
+
+
 def test_async_events_iterator(async_math_contract):
     all_events = async_math_contract.all_events()
     events_iter = async_math_contract.events
@@ -143,3 +235,19 @@ def test_async_events_iterator(async_math_contract):
     for event, expected_event in zip(iter(events_iter), all_events):
         assert isinstance(event, AsyncContractEvent)
         assert event.name == expected_event.name
+
+
+def test_async_contract_event_topic(async_event_contract):
+    event = async_event_contract.events.LogSingleArg()
+    assert event.signature == "LogSingleArg(uint256)"
+    assert event.topic == encode_hex(
+        async_event_contract.w3.keccak(text=event.signature)
+    )
+
+
+def test_async_contract_event_topic_no_parens(async_event_contract):
+    event = async_event_contract.events.LogSingleArg
+    assert event.signature == "LogSingleArg(uint256)"
+    assert event.topic == encode_hex(
+        async_event_contract.w3.keccak(text=event.signature)
+    )
diff --git a/web3/_utils/module_testing/persistent_connection_provider.py b/web3/_utils/module_testing/persistent_connection_provider.py
index 2eec3111..9860bff9 100644
--- a/web3/_utils/module_testing/persistent_connection_provider.py
+++ b/web3/_utils/module_testing/persistent_connection_provider.py
@@ -542,14 +542,13 @@ class PersistentConnectionProviderTest:
         sub_manager = async_w3.subscription_manager
 
         event = async_emitter_contract.events.LogIndexedAndNotIndexed
-        event_topic = async_w3.keccak(text=event.abi_element_identifier).to_0x_hex()
 
         logs_handler_test = SubscriptionHandlerTest()
         sub_id = await async_w3.eth.subscribe(
             "logs",
             {
                 "address": async_emitter_contract.address,
-                "topics": [HexStr(event_topic)],
+                "topics": [event.topic],
             },
             handler=logs_handler,
             handler_context={