Skip to content

Commit 36f279b

Browse files
feat: event arg filtering (#233)
* fix(Runner): PollingRunner did not pass through the topics * fix: allow specifying filter params for event logs * docs(Example): add demo of event log filtering to demo example * docs: document event arg filtering * refactor: use utility method from ethpm-types --------- Co-authored-by: Mike Shultz <shultzm@gmail.com>
1 parent 6d34bf9 commit 36f279b

File tree

4 files changed

+100
-22
lines changed

4 files changed

+100
-22
lines changed

bots/example.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from ape import chain
77
from ape.api import BlockAPI
88
from ape.types import ContractLog
9+
from ape.utils import ZERO_ADDRESS
910
from ape_tokens import tokens # type: ignore[import]
1011
from taskiq import Context, TaskiqDepends, TaskiqState
1112

@@ -82,6 +83,11 @@ def exec_event1(log):
8283
return {"amount": log.amount}
8384

8485

86+
@bot.on_(USDC.Transfer, sender=ZERO_ADDRESS)
87+
async def handle_mints(log):
88+
assert log.sender == ZERO_ADDRESS
89+
90+
8591
@bot.on_(YFI.Approval)
8692
# Any handler function can be async too
8793
async def exec_event2(log: ContractLog):

docs/userguides/development.md

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ If you follow these suggestions, your Silverback deployments will be easy to use
7373

7474
Creating a Silverback Bot is easy, to do so initialize the `silverback.SilverbackBot` class:
7575

76-
```py
76+
```python
7777
from silverback import SilverbackBot
7878

7979
bot = SilverbackBot()
@@ -91,7 +91,7 @@ This method lets us specify which event will trigger the execution of our handle
9191

9292
To add a block handler, you will do the following:
9393

94-
```py
94+
```python
9595
from ape import chain
9696

9797
@bot.on_(chain.blocks)
@@ -107,7 +107,7 @@ Any errors you raise during this function will get captured by the client, and r
107107

108108
Similarly to blocks, you can handle events emitted by a contract by adding an event handler:
109109

110-
```
110+
```python
111111
from ape import Contract
112112

113113
TOKEN = Contract(<your token address here>)
@@ -121,13 +121,32 @@ Inside of `handle_token_transfer_events` you can define any logic that you want
121121
Again, you can return any serializable data structure from this function and that will be stored in the results database as a trackable metric for the execution of this handler.
122122
Any errors you raise during this function will get captured by the client, and recorded as a failure to handle this `transfer` event log.
123123

124+
### Event Log Filters
125+
126+
You can also filter event logs by event parameters.
127+
For example, if you want to handle only `Transfer` events that represent a burn (a transfer to the zero address):
128+
129+
```python
130+
@bot.on_(USDC.Transfer, to="0x0000000000000000000000000000000000000000")
131+
def handle_burn(log):
132+
return {"burned": log.value}
133+
```
134+
135+
In case an event parameter has a name that is an illegal keyword, we can also support a dictionary syntax:
136+
137+
```python
138+
@bot.on_(USDC.Transfer, filter_args={"from":"0x0000000000000000000000000000000000000000"})
139+
def handle_burn(log):
140+
return {"burned": log.value}
141+
```
142+
124143
## Cron Tasks
125144

126145
You may also want to run some tasks according to a schedule, either for efficiency reasons or just that the task is not related to any chain-driven events.
127146
You can do that with the `@cron` task decorator.
128147

129148
```python
130-
@app.cron("* */1 * * *")
149+
@bot.cron("* */1 * * *")
131150
def every_hour():
132151
...
133152
```
@@ -140,7 +159,7 @@ For more information see [the linux handbook section on the crontab syntax](http
140159

141160
If you have heavier resources you want to load during startup, or want to initialize things like database connections, you can add a worker startup function like so:
142161

143-
```py
162+
```python
144163
@bot.on_worker_startup()
145164
def handle_on_worker_startup(state):
146165
# Connect to DB, set initial state, etc
@@ -164,7 +183,7 @@ The `state` variable is also useful as this can be made available to each handle
164183

165184
To access the state from a handler, you must annotate `context` as a dependency like so:
166185

167-
```py
186+
```python
168187
from typing import Annotated
169188
from taskiq import Context, TaskiqDepends
170189

@@ -178,7 +197,7 @@ def block_handler(block, context: Annotated[Context, TaskiqDepends()]):
178197

179198
You can also add an bot startup and shutdown handler that will be **executed once upon every bot startup**. This may be useful for things like processing historical events since the bot was shutdown or other one-time actions to perform at startup.
180199

181-
```py
200+
```python
182201
@bot.on_startup()
183202
def handle_on_startup(startup_state):
184203
# Process missed events, etc
@@ -205,7 +224,7 @@ For example, you might want to pre-populate a large dataframe into state on star
205224
and then use that data to determine a signal under which you want trigger transactions to commit back to the chain.
206225
Such an bot might look like this:
207226

208-
```py
227+
```python
209228
@bot.on_startup()
210229
def create_table(startup_state):
211230
df = contract.MyEvent.query(..., start_block=startup_state.last_block_processed)
@@ -279,7 +298,7 @@ $ silverback run my_bot --network :sepolia --account acct-name
279298
It's important to note that signers are optional, if not configured in the bot then `bot.signer` will be `None`.
280299
You can use this in your bot to enable a "test execution" mode, something like this:
281300

282-
```py
301+
```python
283302
# Compute some metric that might lead to creating a transaction
284303
if bot.signer:
285304
# Execute a transaction via `sender=bot.signer`

silverback/main.py

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
from ape.contracts import ContractEvent, ContractEventWrapper, ContractInstance
1212
from ape.logging import logger
1313
from ape.managers.chain import BlockContainer
14-
from ape.types import ContractLog
14+
from ape.types import AddressType, ContractLog
1515
from ape.utils import ManagerAccessMixin
16+
from eth_typing import HexStr
1617
from eth_utils import keccak, to_hex
18+
from ethpm_types.abi import encode_topic_value
1719
from packaging.version import Version
1820
from pydantic import BaseModel
1921
from taskiq import AsyncTaskiqDecoratedTask, TaskiqEvents
@@ -348,11 +350,33 @@ async def fork_handler(*args, **kwargs):
348350

349351
return fork_handler
350352

353+
def _convert_arg_to_hexstr(self, arg_value: Any, arg_type: str) -> HexStr | list[HexStr] | None:
354+
python_type: Any
355+
if "int" in arg_type:
356+
python_type = int
357+
elif "bytes" in arg_type:
358+
python_type = bytes
359+
elif arg_type == "address":
360+
python_type = AddressType
361+
elif arg_type == "string":
362+
python_type = str
363+
else:
364+
raise ValueError(f"Unable to support ABI Type '{arg_type}'.")
365+
366+
if isinstance(arg_value, list):
367+
arg_value = [self.conversion_manager.convert(v, python_type) for v in arg_value]
368+
369+
else:
370+
arg_value = self.conversion_manager.convert(arg_value, python_type)
371+
372+
return encode_topic_value(arg_type, arg_value) # type: ignore[return-value]
373+
351374
def broker_task_decorator(
352375
self,
353376
task_type: TaskType,
354377
container: BlockContainer | ContractEvent | ContractEventWrapper | None = None,
355378
cron_schedule: str | None = None,
379+
filter_args: dict[str, Any] | None = None,
356380
) -> Callable[[Callable], AsyncTaskiqDecoratedTask]:
357381
"""
358382
Dynamically create a new broker task that handles tasks of ``task_type``.
@@ -411,17 +435,36 @@ def add_taskiq_task(
411435
elif task_type is TaskType.EVENT_LOG:
412436
assert container is not None and isinstance(container, ContractEvent)
413437
# NOTE: allows broad capture filters (matching multiple addresses)
414-
if contract_address := getattr(container.contract, "address", None):
415-
labels["address"] = contract_address
438+
if contract := getattr(container, "contract", None):
439+
labels["address"] = contract.address
440+
416441
labels["event"] = container.abi.signature
417-
labels["topics"] = encode_topics_to_string(
418-
[
419-
# Topic 0: event_id
420-
to_hex(keccak(text=container.abi.selector)),
421-
# Topic 1-4: event args ([..., ...] represent OR)
422-
# TODO: Add filter args
423-
]
424-
)
442+
443+
topics: list[list[HexStr] | HexStr | None] = [
444+
# Topic 0: event_id
445+
to_hex(keccak(text=container.abi.selector))
446+
]
447+
448+
# Topic 1-3: event args ([..., ...] represent OR)
449+
if filter_args:
450+
for arg in container.abi.inputs:
451+
if not arg.indexed:
452+
break # Inputs should be ordered indexed first
453+
454+
if arg_value := filter_args.pop(arg.name, None):
455+
topics.append(self._convert_arg_to_hexstr(arg_value, arg.type))
456+
457+
else:
458+
# Skip this indexed argument (`None` is wildcard match)
459+
topics.append(None)
460+
# NOTE: Will clean up extra Nones in `encode_topics_to_string`
461+
462+
if unmatched_args := "', '".join(filter_args):
463+
raise InvalidContainerTypeError(
464+
f"Args are not available for filtering: '{unmatched_args}'."
465+
)
466+
467+
labels["topics"] = encode_topics_to_string(topics)
425468

426469
handler = self._ensure_log(container, handler)
427470

@@ -518,6 +561,8 @@ def on_(
518561
# TODO: possibly remove these
519562
new_block_timeout: int | None = None,
520563
start_block: int | None = None,
564+
filter_args: dict[str, Any] | None = None,
565+
**filter_kwargs: dict[str, Any],
521566
):
522567
"""
523568
Create task to handle events created by the `container` trigger.
@@ -565,7 +610,14 @@ def on_(
565610
else:
566611
self.poll_settings[key] = {"start_block": start_block}
567612

568-
return self.broker_task_decorator(TaskType.EVENT_LOG, container=container)
613+
if filter_args:
614+
filter_kwargs.update(filter_args)
615+
616+
return self.broker_task_decorator(
617+
TaskType.EVENT_LOG,
618+
container=container,
619+
filter_args=filter_kwargs,
620+
)
569621

570622
# TODO: Support account transaction polling
571623
# TODO: Support mempool polling?

silverback/runner.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,8 +444,9 @@ async def _block_task(self, task_data: TaskData):
444444
async def _event_task(self, task_data: TaskData):
445445
contract_address = task_data.labels.get("address")
446446
event = EventABI.from_signature(task_data.labels["event"])
447+
topics = decode_topics_from_string(task_data.labels.get("topics", "")) or None
447448
async for log in async_wrap_iter(
448449
# NOTE: No start block because we should begin polling from head
449-
self.provider.poll_logs(address=contract_address, events=[event])
450+
self.provider.poll_logs(address=contract_address, events=[event], topics=topics)
450451
):
451452
self._runtime_task_group.create_task(self.run_task(task_data, log))

0 commit comments

Comments
 (0)