tinybot-eth 0.5.0__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/PKG-INFO +42 -8
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/README.md +40 -7
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/pyproject.toml +2 -1
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/tinybot/__init__.py +2 -1
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/tinybot/bot.py +42 -6
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/tinybot/executor.py +13 -4
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/tinybot/multicall.py +23 -8
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/tinybot/tg.py +7 -9
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/tinybot/types.py +8 -0
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/tinybot_eth.egg-info/PKG-INFO +42 -8
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/tinybot_eth.egg-info/requires.txt +1 -0
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/setup.cfg +0 -0
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/tinybot/state.py +0 -0
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/tinybot/utils.py +0 -0
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/tinybot_eth.egg-info/SOURCES.txt +0 -0
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/tinybot_eth.egg-info/dependency_links.txt +0 -0
- {tinybot_eth-0.5.0 → tinybot_eth-0.7.0}/tinybot_eth.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tinybot-eth
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Minimal Python framework for building crypto bots
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -8,6 +8,7 @@ Requires-Dist: web3==7.14.1
|
|
|
8
8
|
Requires-Dist: eth-abi==5.2.0
|
|
9
9
|
Requires-Dist: eth-account==0.13.7
|
|
10
10
|
Requires-Dist: python-telegram-bot==22.7
|
|
11
|
+
Requires-Dist: croniter==6.2.2
|
|
11
12
|
|
|
12
13
|
# tinybot
|
|
13
14
|
|
|
@@ -23,9 +24,9 @@ pip install tinybot-eth
|
|
|
23
24
|
|
|
24
25
|
| Variable | Required | Description |
|
|
25
26
|
|---|---|---|
|
|
26
|
-
| `BOT_ACCESS_TOKEN` |
|
|
27
|
-
| `GROUP_CHAT_ID` |
|
|
28
|
-
| `DEV_GROUP_CHAT_ID` |
|
|
27
|
+
| `BOT_ACCESS_TOKEN` | No | Telegram bot token. Unset: `notify_group_chat` does nothing and `telegram_enabled()` is `False` |
|
|
28
|
+
| `GROUP_CHAT_ID` | No | Telegram group for notifications |
|
|
29
|
+
| `DEV_GROUP_CHAT_ID` | No | Telegram group for errors and startup |
|
|
29
30
|
| `PRIVATE_KEY` | No | Private key for onchain execution |
|
|
30
31
|
|
|
31
32
|
## Quick Start
|
|
@@ -34,6 +35,7 @@ bot = TinyBot(rpc_url, name="my bot")
|
|
|
34
35
|
|
|
35
36
|
bot.listen(event="AuctionKicked", handler=on_kick, ...)
|
|
36
37
|
bot.every(180, check_expired)
|
|
38
|
+
bot.cron("0 * * * *", hourly_check)
|
|
37
39
|
|
|
38
40
|
await bot.run()
|
|
39
41
|
```
|
|
@@ -83,7 +85,7 @@ asyncio.run(main())
|
|
|
83
85
|
|
|
84
86
|
## API
|
|
85
87
|
|
|
86
|
-
### `TinyBot(rpc_url, name="tinybot", private_key="")`
|
|
88
|
+
### `TinyBot(rpc_url, private_rpc_url="", name="tinybot", private_key="")`
|
|
87
89
|
|
|
88
90
|
Creates a bot instance.
|
|
89
91
|
|
|
@@ -91,6 +93,7 @@ Creates a bot instance.
|
|
|
91
93
|
- `bot.state` — `State` instance (see below)
|
|
92
94
|
- `bot.executor` — `Executor` instance if `private_key` is provided, else `None`
|
|
93
95
|
- `bot.name` — used in logs and Telegram startup message
|
|
96
|
+
- `private_rpc_url` — optional private relay (e.g. `https://rpc.flashbots.net/fast`), used only for txs sent with `execute(..., private=True)`; reads and public txs stay on `rpc_url`
|
|
94
97
|
|
|
95
98
|
On `run()`, sends a startup message to `DEV_GROUP_CHAT_ID` and prints a polling heartbeat every tick.
|
|
96
99
|
|
|
@@ -132,6 +135,19 @@ Handler signature: `async fn(bot)`
|
|
|
132
135
|
|
|
133
136
|
---
|
|
134
137
|
|
|
138
|
+
### `bot.cron(expression, handler, name="", notify_errors=True) -> CronTask`
|
|
139
|
+
|
|
140
|
+
Register a cron-scheduled task. Uses standard cron expressions.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
bot.cron("0 * * * *", notify_ending_soon) # every hour at :00
|
|
144
|
+
bot.cron("*/30 * * * *", check_something) # every 30 minutes
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Handler signature: `async fn(bot)`
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
135
151
|
### `bot.get_listener(name) -> EventListener`
|
|
136
152
|
|
|
137
153
|
Get a registered listener by name. Raises `ValueError` if not found.
|
|
@@ -179,16 +195,20 @@ tx_hash = bot.executor.execute(
|
|
|
179
195
|
max_priority_fee_gwei=0, # default: 0
|
|
180
196
|
simulate=True, # default: True — dry-run via eth_call before sending
|
|
181
197
|
wait=120, # default: 120 — seconds to wait for mining (0 = fire and forget)
|
|
198
|
+
private=False, # default: False — True broadcasts via private_rpc_url
|
|
199
|
+
replace_pending=False, # default: False — True reuses the nonce of an unmined tx to replace it
|
|
182
200
|
)
|
|
183
201
|
```
|
|
184
202
|
|
|
185
203
|
- `executor.address` — signer address
|
|
186
204
|
- `executor.balance` — signer ETH balance in wei
|
|
187
|
-
- `executor.execute(call, ...)` — sign and broadcast a transaction, returns tx hash
|
|
205
|
+
- `executor.execute(call, ...)` — sign and broadcast a transaction, returns the `0x`-prefixed tx hash
|
|
188
206
|
- `gas_limit=0` (default) — auto-estimates gas with 1.5x buffer; pass a value to override
|
|
189
207
|
- `max_fee_gwei` / `max_priority_fee_gwei` — accept floats (e.g. `0.1`)
|
|
190
208
|
- `simulate=True` (default) — runs `call.call()` first; reverts raise before the tx is sent
|
|
191
209
|
- `wait=120` (default) — wait up to N seconds for the tx to be mined; `0` for fire and forget
|
|
210
|
+
- `replace_pending=False` (default) — nonce from the `pending` block, so a tx sent while an earlier one is unmined queues behind it; `replace_pending=True` — nonce from the `latest` block, so the tx replaces the unmined one (the node requires a ~10% higher fee)
|
|
211
|
+
- `private=False` (default) — broadcasts via `rpc_url`; `private=True` broadcasts via `private_rpc_url` and raises if it is not set. There is no fallback between the two, so a private tx never goes public
|
|
192
212
|
|
|
193
213
|
---
|
|
194
214
|
|
|
@@ -205,7 +225,7 @@ In-memory state, available via `bot.state`.
|
|
|
205
225
|
|
|
206
226
|
---
|
|
207
227
|
|
|
208
|
-
### `multicall(w3, calls) -> list`
|
|
228
|
+
### `multicall(w3, calls, allow_failure=False, batch_size=200) -> list`
|
|
209
229
|
|
|
210
230
|
Batch contract reads via [Multicall3](https://github.com/mds1/multicall).
|
|
211
231
|
|
|
@@ -214,13 +234,27 @@ symbol, decimals = multicall(bot.w3, [
|
|
|
214
234
|
token.functions.symbol(),
|
|
215
235
|
token.functions.decimals(),
|
|
216
236
|
])
|
|
237
|
+
|
|
238
|
+
# one result per call; None where the call reverted or returned nothing
|
|
239
|
+
keepers = multicall(bot.w3, [s.functions.keeper() for s in strategies], allow_failure=True)
|
|
217
240
|
```
|
|
218
241
|
|
|
242
|
+
- `allow_failure=False` (default) — any reverting call reverts the whole batch
|
|
243
|
+
- `allow_failure=True` — a reverting call, empty return data, or undecodable data yields `None`
|
|
244
|
+
- `batch_size=200` — calls are split into chunks of this size to stay under RPC `eth_call` gas caps
|
|
245
|
+
- Functions returning tuples/structs are decoded
|
|
246
|
+
|
|
219
247
|
---
|
|
220
248
|
|
|
221
249
|
### `notify_group_chat(text, parse_mode="HTML", chat_id=GROUP_CHAT_ID)`
|
|
222
250
|
|
|
223
|
-
Send a Telegram message. HTML parse mode by default.
|
|
251
|
+
Send a Telegram message. HTML parse mode by default. Does nothing when `BOT_ACCESS_TOKEN` or the chat id is unset.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
### `telegram_enabled() -> bool`
|
|
256
|
+
|
|
257
|
+
`True` when `BOT_ACCESS_TOKEN` is set. Use it to skip work whose only output is a Telegram message.
|
|
224
258
|
|
|
225
259
|
---
|
|
226
260
|
|
|
@@ -12,9 +12,9 @@ pip install tinybot-eth
|
|
|
12
12
|
|
|
13
13
|
| Variable | Required | Description |
|
|
14
14
|
|---|---|---|
|
|
15
|
-
| `BOT_ACCESS_TOKEN` |
|
|
16
|
-
| `GROUP_CHAT_ID` |
|
|
17
|
-
| `DEV_GROUP_CHAT_ID` |
|
|
15
|
+
| `BOT_ACCESS_TOKEN` | No | Telegram bot token. Unset: `notify_group_chat` does nothing and `telegram_enabled()` is `False` |
|
|
16
|
+
| `GROUP_CHAT_ID` | No | Telegram group for notifications |
|
|
17
|
+
| `DEV_GROUP_CHAT_ID` | No | Telegram group for errors and startup |
|
|
18
18
|
| `PRIVATE_KEY` | No | Private key for onchain execution |
|
|
19
19
|
|
|
20
20
|
## Quick Start
|
|
@@ -23,6 +23,7 @@ bot = TinyBot(rpc_url, name="my bot")
|
|
|
23
23
|
|
|
24
24
|
bot.listen(event="AuctionKicked", handler=on_kick, ...)
|
|
25
25
|
bot.every(180, check_expired)
|
|
26
|
+
bot.cron("0 * * * *", hourly_check)
|
|
26
27
|
|
|
27
28
|
await bot.run()
|
|
28
29
|
```
|
|
@@ -72,7 +73,7 @@ asyncio.run(main())
|
|
|
72
73
|
|
|
73
74
|
## API
|
|
74
75
|
|
|
75
|
-
### `TinyBot(rpc_url, name="tinybot", private_key="")`
|
|
76
|
+
### `TinyBot(rpc_url, private_rpc_url="", name="tinybot", private_key="")`
|
|
76
77
|
|
|
77
78
|
Creates a bot instance.
|
|
78
79
|
|
|
@@ -80,6 +81,7 @@ Creates a bot instance.
|
|
|
80
81
|
- `bot.state` — `State` instance (see below)
|
|
81
82
|
- `bot.executor` — `Executor` instance if `private_key` is provided, else `None`
|
|
82
83
|
- `bot.name` — used in logs and Telegram startup message
|
|
84
|
+
- `private_rpc_url` — optional private relay (e.g. `https://rpc.flashbots.net/fast`), used only for txs sent with `execute(..., private=True)`; reads and public txs stay on `rpc_url`
|
|
83
85
|
|
|
84
86
|
On `run()`, sends a startup message to `DEV_GROUP_CHAT_ID` and prints a polling heartbeat every tick.
|
|
85
87
|
|
|
@@ -121,6 +123,19 @@ Handler signature: `async fn(bot)`
|
|
|
121
123
|
|
|
122
124
|
---
|
|
123
125
|
|
|
126
|
+
### `bot.cron(expression, handler, name="", notify_errors=True) -> CronTask`
|
|
127
|
+
|
|
128
|
+
Register a cron-scheduled task. Uses standard cron expressions.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
bot.cron("0 * * * *", notify_ending_soon) # every hour at :00
|
|
132
|
+
bot.cron("*/30 * * * *", check_something) # every 30 minutes
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Handler signature: `async fn(bot)`
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
124
139
|
### `bot.get_listener(name) -> EventListener`
|
|
125
140
|
|
|
126
141
|
Get a registered listener by name. Raises `ValueError` if not found.
|
|
@@ -168,16 +183,20 @@ tx_hash = bot.executor.execute(
|
|
|
168
183
|
max_priority_fee_gwei=0, # default: 0
|
|
169
184
|
simulate=True, # default: True — dry-run via eth_call before sending
|
|
170
185
|
wait=120, # default: 120 — seconds to wait for mining (0 = fire and forget)
|
|
186
|
+
private=False, # default: False — True broadcasts via private_rpc_url
|
|
187
|
+
replace_pending=False, # default: False — True reuses the nonce of an unmined tx to replace it
|
|
171
188
|
)
|
|
172
189
|
```
|
|
173
190
|
|
|
174
191
|
- `executor.address` — signer address
|
|
175
192
|
- `executor.balance` — signer ETH balance in wei
|
|
176
|
-
- `executor.execute(call, ...)` — sign and broadcast a transaction, returns tx hash
|
|
193
|
+
- `executor.execute(call, ...)` — sign and broadcast a transaction, returns the `0x`-prefixed tx hash
|
|
177
194
|
- `gas_limit=0` (default) — auto-estimates gas with 1.5x buffer; pass a value to override
|
|
178
195
|
- `max_fee_gwei` / `max_priority_fee_gwei` — accept floats (e.g. `0.1`)
|
|
179
196
|
- `simulate=True` (default) — runs `call.call()` first; reverts raise before the tx is sent
|
|
180
197
|
- `wait=120` (default) — wait up to N seconds for the tx to be mined; `0` for fire and forget
|
|
198
|
+
- `replace_pending=False` (default) — nonce from the `pending` block, so a tx sent while an earlier one is unmined queues behind it; `replace_pending=True` — nonce from the `latest` block, so the tx replaces the unmined one (the node requires a ~10% higher fee)
|
|
199
|
+
- `private=False` (default) — broadcasts via `rpc_url`; `private=True` broadcasts via `private_rpc_url` and raises if it is not set. There is no fallback between the two, so a private tx never goes public
|
|
181
200
|
|
|
182
201
|
---
|
|
183
202
|
|
|
@@ -194,7 +213,7 @@ In-memory state, available via `bot.state`.
|
|
|
194
213
|
|
|
195
214
|
---
|
|
196
215
|
|
|
197
|
-
### `multicall(w3, calls) -> list`
|
|
216
|
+
### `multicall(w3, calls, allow_failure=False, batch_size=200) -> list`
|
|
198
217
|
|
|
199
218
|
Batch contract reads via [Multicall3](https://github.com/mds1/multicall).
|
|
200
219
|
|
|
@@ -203,13 +222,27 @@ symbol, decimals = multicall(bot.w3, [
|
|
|
203
222
|
token.functions.symbol(),
|
|
204
223
|
token.functions.decimals(),
|
|
205
224
|
])
|
|
225
|
+
|
|
226
|
+
# one result per call; None where the call reverted or returned nothing
|
|
227
|
+
keepers = multicall(bot.w3, [s.functions.keeper() for s in strategies], allow_failure=True)
|
|
206
228
|
```
|
|
207
229
|
|
|
230
|
+
- `allow_failure=False` (default) — any reverting call reverts the whole batch
|
|
231
|
+
- `allow_failure=True` — a reverting call, empty return data, or undecodable data yields `None`
|
|
232
|
+
- `batch_size=200` — calls are split into chunks of this size to stay under RPC `eth_call` gas caps
|
|
233
|
+
- Functions returning tuples/structs are decoded
|
|
234
|
+
|
|
208
235
|
---
|
|
209
236
|
|
|
210
237
|
### `notify_group_chat(text, parse_mode="HTML", chat_id=GROUP_CHAT_ID)`
|
|
211
238
|
|
|
212
|
-
Send a Telegram message. HTML parse mode by default.
|
|
239
|
+
Send a Telegram message. HTML parse mode by default. Does nothing when `BOT_ACCESS_TOKEN` or the chat id is unset.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
### `telegram_enabled() -> bool`
|
|
244
|
+
|
|
245
|
+
`True` when `BOT_ACCESS_TOKEN` is set. Use it to skip work whose only output is a Telegram message.
|
|
213
246
|
|
|
214
247
|
---
|
|
215
248
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tinybot-eth"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.0"
|
|
4
4
|
description = "Minimal Python framework for building crypto bots"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -9,6 +9,7 @@ dependencies = [
|
|
|
9
9
|
"eth-abi==5.2.0",
|
|
10
10
|
"eth-account==0.13.7",
|
|
11
11
|
"python-telegram-bot==22.7",
|
|
12
|
+
"croniter==6.2.2",
|
|
12
13
|
]
|
|
13
14
|
|
|
14
15
|
[tool.setuptools.packages.find]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from tinybot.bot import TinyBot
|
|
2
2
|
from tinybot.executor import Executor
|
|
3
3
|
from tinybot.multicall import multicall
|
|
4
|
-
from tinybot.tg import DEV_GROUP_CHAT_ID, notify_group_chat
|
|
4
|
+
from tinybot.tg import DEV_GROUP_CHAT_ID, notify_group_chat, telegram_enabled
|
|
5
5
|
from tinybot.utils import event_id
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
@@ -11,4 +11,5 @@ __all__ = [
|
|
|
11
11
|
"event_id",
|
|
12
12
|
"multicall",
|
|
13
13
|
"notify_group_chat",
|
|
14
|
+
"telegram_enabled",
|
|
14
15
|
]
|
|
@@ -8,18 +8,22 @@ from web3 import Web3
|
|
|
8
8
|
from tinybot.executor import Executor
|
|
9
9
|
from tinybot.state import State
|
|
10
10
|
from tinybot.tg import DEV_GROUP_CHAT_ID, notify_group_chat
|
|
11
|
-
from
|
|
11
|
+
from croniter import croniter
|
|
12
|
+
|
|
13
|
+
from tinybot.types import CronTask, EventHandler, EventListener, PeriodicTask, TaskHandler
|
|
12
14
|
from tinybot.utils import event_id, event_signature
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
class TinyBot:
|
|
16
|
-
def __init__(self, rpc_url: str, name: str = "tinybot", private_key: str = ""):
|
|
17
|
-
self.w3 = Web3(Web3.HTTPProvider(rpc_url))
|
|
18
|
+
def __init__(self, rpc_url: str, private_rpc_url: str = "", name: str = "tinybot", private_key: str = ""):
|
|
19
|
+
self.w3 = Web3(Web3.HTTPProvider(rpc_url, cache_allowed_requests=True, cacheable_requests={"eth_chainId"}))
|
|
18
20
|
self.name = name
|
|
19
21
|
self.state = State()
|
|
20
|
-
self.
|
|
22
|
+
self._private_w3 = Web3(Web3.HTTPProvider(private_rpc_url)) if private_rpc_url else None
|
|
23
|
+
self.executor = Executor(self.w3, private_key, self._private_w3) if private_key else None
|
|
21
24
|
self._listeners: list[EventListener] = []
|
|
22
25
|
self._tasks: list[PeriodicTask] = []
|
|
26
|
+
self._crons: list[tuple[CronTask, croniter]] = []
|
|
23
27
|
|
|
24
28
|
# -------------------------------------------------------------------------
|
|
25
29
|
# Registration
|
|
@@ -77,6 +81,26 @@ class TinyBot:
|
|
|
77
81
|
self._tasks.append(task)
|
|
78
82
|
return task
|
|
79
83
|
|
|
84
|
+
def cron(
|
|
85
|
+
self,
|
|
86
|
+
expression: str,
|
|
87
|
+
handler: TaskHandler,
|
|
88
|
+
name: str = "",
|
|
89
|
+
notify_errors: bool = True,
|
|
90
|
+
) -> CronTask:
|
|
91
|
+
name = name or handler.__name__
|
|
92
|
+
if any(task.name == name for task, _ in self._crons):
|
|
93
|
+
raise ValueError(f"cron '{name}' already registered")
|
|
94
|
+
task = CronTask(
|
|
95
|
+
name=name,
|
|
96
|
+
expression=expression,
|
|
97
|
+
handler=handler,
|
|
98
|
+
notify_errors=notify_errors,
|
|
99
|
+
)
|
|
100
|
+
cron = croniter(expression, datetime.now())
|
|
101
|
+
self._crons.append((task, cron))
|
|
102
|
+
return task
|
|
103
|
+
|
|
80
104
|
# -------------------------------------------------------------------------
|
|
81
105
|
# Getters
|
|
82
106
|
# -------------------------------------------------------------------------
|
|
@@ -149,7 +173,7 @@ class TinyBot:
|
|
|
149
173
|
except Exception as e:
|
|
150
174
|
await self._handle_error(e, listener.name, listener.notify_errors)
|
|
151
175
|
|
|
152
|
-
async def
|
|
176
|
+
async def _poll_periodic(self, task: PeriodicTask) -> None:
|
|
153
177
|
now = time.time()
|
|
154
178
|
if now - task._last_run < task.interval:
|
|
155
179
|
return
|
|
@@ -160,6 +184,16 @@ class TinyBot:
|
|
|
160
184
|
except Exception as e:
|
|
161
185
|
await self._handle_error(e, task.name, task.notify_errors)
|
|
162
186
|
|
|
187
|
+
async def _poll_cron(self, task: CronTask, cron: croniter) -> None:
|
|
188
|
+
now = datetime.now()
|
|
189
|
+
next_run = cron.get_current(datetime)
|
|
190
|
+
if now >= next_run:
|
|
191
|
+
try:
|
|
192
|
+
await task.handler(self)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
await self._handle_error(e, task.name, task.notify_errors)
|
|
195
|
+
cron.get_next(datetime)
|
|
196
|
+
|
|
163
197
|
# -------------------------------------------------------------------------
|
|
164
198
|
# Run
|
|
165
199
|
# -------------------------------------------------------------------------
|
|
@@ -175,5 +209,7 @@ class TinyBot:
|
|
|
175
209
|
for listener in self._listeners:
|
|
176
210
|
await self._poll_listener(listener)
|
|
177
211
|
for task in self._tasks:
|
|
178
|
-
await self.
|
|
212
|
+
await self._poll_periodic(task)
|
|
213
|
+
for task, cron in self._crons:
|
|
214
|
+
await self._poll_cron(task, cron)
|
|
179
215
|
await asyncio.sleep(tick)
|
|
@@ -4,8 +4,9 @@ from web3 import Web3
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class Executor:
|
|
7
|
-
def __init__(self, w3: Web3, private_key: str):
|
|
7
|
+
def __init__(self, w3: Web3, private_key: str, private_w3: Web3 | None = None):
|
|
8
8
|
self._w3 = w3
|
|
9
|
+
self._private_w3 = private_w3 # optional private relay, used only for txs sent with `private=True`
|
|
9
10
|
self._account: LocalAccount = Account.from_key(private_key)
|
|
10
11
|
|
|
11
12
|
@property
|
|
@@ -25,7 +26,12 @@ class Executor:
|
|
|
25
26
|
value: int = 0,
|
|
26
27
|
simulate: bool = True,
|
|
27
28
|
wait: int = 120,
|
|
29
|
+
private: bool = False,
|
|
30
|
+
replace_pending: bool = False,
|
|
28
31
|
) -> str:
|
|
32
|
+
if private and self._private_w3 is None:
|
|
33
|
+
raise ValueError("private tx requested but no private_rpc_url is set")
|
|
34
|
+
|
|
29
35
|
if simulate:
|
|
30
36
|
call.call({"from": self._account.address, "value": value})
|
|
31
37
|
|
|
@@ -35,7 +41,9 @@ class Executor:
|
|
|
35
41
|
tx = call.build_transaction(
|
|
36
42
|
{
|
|
37
43
|
"from": self._account.address,
|
|
38
|
-
"nonce": self._w3.eth.get_transaction_count(
|
|
44
|
+
"nonce": self._w3.eth.get_transaction_count(
|
|
45
|
+
self._account.address, "latest" if replace_pending else "pending"
|
|
46
|
+
),
|
|
39
47
|
"gas": gas_limit,
|
|
40
48
|
"maxFeePerGas": self._w3.to_wei(max_fee_gwei, "gwei"),
|
|
41
49
|
"maxPriorityFeePerGas": self._w3.to_wei(max_priority_fee_gwei, "gwei"),
|
|
@@ -43,7 +51,8 @@ class Executor:
|
|
|
43
51
|
}
|
|
44
52
|
)
|
|
45
53
|
signed = self._w3.eth.account.sign_transaction(tx, self._account.key)
|
|
46
|
-
|
|
54
|
+
send_w3 = self._private_w3 if private else self._w3
|
|
55
|
+
tx_hash = send_w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
47
56
|
if wait > 0:
|
|
48
57
|
self._w3.eth.wait_for_transaction_receipt(tx_hash, timeout=wait)
|
|
49
|
-
return tx_hash.
|
|
58
|
+
return tx_hash.to_0x_hex()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from eth_abi import decode as decode_abi
|
|
2
|
+
from eth_utils import get_abi_output_types
|
|
2
3
|
from web3 import Web3
|
|
3
4
|
|
|
4
5
|
# Multicall3 — same address on all chains
|
|
@@ -33,14 +34,28 @@ MULTICALL3_ABI = [
|
|
|
33
34
|
]
|
|
34
35
|
|
|
35
36
|
|
|
36
|
-
def multicall(w3: Web3, calls: list) -> list:
|
|
37
|
-
"""Batch contract calls. calls = list of ContractFunction objects.
|
|
37
|
+
def multicall(w3: Web3, calls: list, allow_failure: bool = False, batch_size: int = 200) -> list:
|
|
38
|
+
"""Batch contract calls. calls = list of ContractFunction objects.
|
|
39
|
+
|
|
40
|
+
With allow_failure=True, a reverting call (or one that returns no data) yields None instead of
|
|
41
|
+
reverting the whole batch. Calls are sent in chunks of batch_size to stay under eth_call gas caps.
|
|
42
|
+
"""
|
|
38
43
|
mc = w3.eth.contract(address=MULTICALL3, abi=MULTICALL3_ABI)
|
|
39
|
-
encoded = [(call.address, False, call._encode_transaction_data()) for call in calls]
|
|
40
|
-
results = mc.functions.aggregate3(encoded).call()
|
|
41
44
|
decoded = []
|
|
42
|
-
for
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
for i in range(0, len(calls), batch_size):
|
|
46
|
+
chunk = calls[i : i + batch_size]
|
|
47
|
+
encoded = [(call.address, allow_failure, call._encode_transaction_data()) for call in chunk]
|
|
48
|
+
results = mc.functions.aggregate3(encoded).call()
|
|
49
|
+
for call, (success, data) in zip(chunk, results):
|
|
50
|
+
if allow_failure and (not success or not data):
|
|
51
|
+
decoded.append(None)
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
result = decode_abi(get_abi_output_types(call.abi), data)
|
|
55
|
+
except Exception:
|
|
56
|
+
if not allow_failure:
|
|
57
|
+
raise
|
|
58
|
+
decoded.append(None)
|
|
59
|
+
continue
|
|
60
|
+
decoded.append(result[0] if len(result) == 1 else result)
|
|
46
61
|
return decoded
|
|
@@ -2,17 +2,13 @@ import os
|
|
|
2
2
|
|
|
3
3
|
from telegram import Bot
|
|
4
4
|
|
|
5
|
+
BOT_ACCESS_TOKEN = os.getenv("BOT_ACCESS_TOKEN", "")
|
|
6
|
+
GROUP_CHAT_ID = int(os.getenv("GROUP_CHAT_ID", "0"))
|
|
7
|
+
DEV_GROUP_CHAT_ID = int(os.getenv("DEV_GROUP_CHAT_ID", "0"))
|
|
5
8
|
|
|
6
|
-
def _require_env(name: str) -> str:
|
|
7
|
-
val = os.getenv(name, "")
|
|
8
|
-
if not val:
|
|
9
|
-
raise RuntimeError(f"!{name}")
|
|
10
|
-
return val
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
GROUP_CHAT_ID = int(_require_env("GROUP_CHAT_ID"))
|
|
15
|
-
DEV_GROUP_CHAT_ID = int(_require_env("DEV_GROUP_CHAT_ID"))
|
|
10
|
+
def telegram_enabled() -> bool:
|
|
11
|
+
return bool(BOT_ACCESS_TOKEN)
|
|
16
12
|
|
|
17
13
|
|
|
18
14
|
async def notify_group_chat(
|
|
@@ -21,6 +17,8 @@ async def notify_group_chat(
|
|
|
21
17
|
chat_id: int = GROUP_CHAT_ID,
|
|
22
18
|
disable_web_page_preview: bool = True,
|
|
23
19
|
) -> None:
|
|
20
|
+
if not BOT_ACCESS_TOKEN or not chat_id:
|
|
21
|
+
return
|
|
24
22
|
try:
|
|
25
23
|
bot = Bot(token=BOT_ACCESS_TOKEN)
|
|
26
24
|
await bot.send_message(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tinybot-eth
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Minimal Python framework for building crypto bots
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -8,6 +8,7 @@ Requires-Dist: web3==7.14.1
|
|
|
8
8
|
Requires-Dist: eth-abi==5.2.0
|
|
9
9
|
Requires-Dist: eth-account==0.13.7
|
|
10
10
|
Requires-Dist: python-telegram-bot==22.7
|
|
11
|
+
Requires-Dist: croniter==6.2.2
|
|
11
12
|
|
|
12
13
|
# tinybot
|
|
13
14
|
|
|
@@ -23,9 +24,9 @@ pip install tinybot-eth
|
|
|
23
24
|
|
|
24
25
|
| Variable | Required | Description |
|
|
25
26
|
|---|---|---|
|
|
26
|
-
| `BOT_ACCESS_TOKEN` |
|
|
27
|
-
| `GROUP_CHAT_ID` |
|
|
28
|
-
| `DEV_GROUP_CHAT_ID` |
|
|
27
|
+
| `BOT_ACCESS_TOKEN` | No | Telegram bot token. Unset: `notify_group_chat` does nothing and `telegram_enabled()` is `False` |
|
|
28
|
+
| `GROUP_CHAT_ID` | No | Telegram group for notifications |
|
|
29
|
+
| `DEV_GROUP_CHAT_ID` | No | Telegram group for errors and startup |
|
|
29
30
|
| `PRIVATE_KEY` | No | Private key for onchain execution |
|
|
30
31
|
|
|
31
32
|
## Quick Start
|
|
@@ -34,6 +35,7 @@ bot = TinyBot(rpc_url, name="my bot")
|
|
|
34
35
|
|
|
35
36
|
bot.listen(event="AuctionKicked", handler=on_kick, ...)
|
|
36
37
|
bot.every(180, check_expired)
|
|
38
|
+
bot.cron("0 * * * *", hourly_check)
|
|
37
39
|
|
|
38
40
|
await bot.run()
|
|
39
41
|
```
|
|
@@ -83,7 +85,7 @@ asyncio.run(main())
|
|
|
83
85
|
|
|
84
86
|
## API
|
|
85
87
|
|
|
86
|
-
### `TinyBot(rpc_url, name="tinybot", private_key="")`
|
|
88
|
+
### `TinyBot(rpc_url, private_rpc_url="", name="tinybot", private_key="")`
|
|
87
89
|
|
|
88
90
|
Creates a bot instance.
|
|
89
91
|
|
|
@@ -91,6 +93,7 @@ Creates a bot instance.
|
|
|
91
93
|
- `bot.state` — `State` instance (see below)
|
|
92
94
|
- `bot.executor` — `Executor` instance if `private_key` is provided, else `None`
|
|
93
95
|
- `bot.name` — used in logs and Telegram startup message
|
|
96
|
+
- `private_rpc_url` — optional private relay (e.g. `https://rpc.flashbots.net/fast`), used only for txs sent with `execute(..., private=True)`; reads and public txs stay on `rpc_url`
|
|
94
97
|
|
|
95
98
|
On `run()`, sends a startup message to `DEV_GROUP_CHAT_ID` and prints a polling heartbeat every tick.
|
|
96
99
|
|
|
@@ -132,6 +135,19 @@ Handler signature: `async fn(bot)`
|
|
|
132
135
|
|
|
133
136
|
---
|
|
134
137
|
|
|
138
|
+
### `bot.cron(expression, handler, name="", notify_errors=True) -> CronTask`
|
|
139
|
+
|
|
140
|
+
Register a cron-scheduled task. Uses standard cron expressions.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
bot.cron("0 * * * *", notify_ending_soon) # every hour at :00
|
|
144
|
+
bot.cron("*/30 * * * *", check_something) # every 30 minutes
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Handler signature: `async fn(bot)`
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
135
151
|
### `bot.get_listener(name) -> EventListener`
|
|
136
152
|
|
|
137
153
|
Get a registered listener by name. Raises `ValueError` if not found.
|
|
@@ -179,16 +195,20 @@ tx_hash = bot.executor.execute(
|
|
|
179
195
|
max_priority_fee_gwei=0, # default: 0
|
|
180
196
|
simulate=True, # default: True — dry-run via eth_call before sending
|
|
181
197
|
wait=120, # default: 120 — seconds to wait for mining (0 = fire and forget)
|
|
198
|
+
private=False, # default: False — True broadcasts via private_rpc_url
|
|
199
|
+
replace_pending=False, # default: False — True reuses the nonce of an unmined tx to replace it
|
|
182
200
|
)
|
|
183
201
|
```
|
|
184
202
|
|
|
185
203
|
- `executor.address` — signer address
|
|
186
204
|
- `executor.balance` — signer ETH balance in wei
|
|
187
|
-
- `executor.execute(call, ...)` — sign and broadcast a transaction, returns tx hash
|
|
205
|
+
- `executor.execute(call, ...)` — sign and broadcast a transaction, returns the `0x`-prefixed tx hash
|
|
188
206
|
- `gas_limit=0` (default) — auto-estimates gas with 1.5x buffer; pass a value to override
|
|
189
207
|
- `max_fee_gwei` / `max_priority_fee_gwei` — accept floats (e.g. `0.1`)
|
|
190
208
|
- `simulate=True` (default) — runs `call.call()` first; reverts raise before the tx is sent
|
|
191
209
|
- `wait=120` (default) — wait up to N seconds for the tx to be mined; `0` for fire and forget
|
|
210
|
+
- `replace_pending=False` (default) — nonce from the `pending` block, so a tx sent while an earlier one is unmined queues behind it; `replace_pending=True` — nonce from the `latest` block, so the tx replaces the unmined one (the node requires a ~10% higher fee)
|
|
211
|
+
- `private=False` (default) — broadcasts via `rpc_url`; `private=True` broadcasts via `private_rpc_url` and raises if it is not set. There is no fallback between the two, so a private tx never goes public
|
|
192
212
|
|
|
193
213
|
---
|
|
194
214
|
|
|
@@ -205,7 +225,7 @@ In-memory state, available via `bot.state`.
|
|
|
205
225
|
|
|
206
226
|
---
|
|
207
227
|
|
|
208
|
-
### `multicall(w3, calls) -> list`
|
|
228
|
+
### `multicall(w3, calls, allow_failure=False, batch_size=200) -> list`
|
|
209
229
|
|
|
210
230
|
Batch contract reads via [Multicall3](https://github.com/mds1/multicall).
|
|
211
231
|
|
|
@@ -214,13 +234,27 @@ symbol, decimals = multicall(bot.w3, [
|
|
|
214
234
|
token.functions.symbol(),
|
|
215
235
|
token.functions.decimals(),
|
|
216
236
|
])
|
|
237
|
+
|
|
238
|
+
# one result per call; None where the call reverted or returned nothing
|
|
239
|
+
keepers = multicall(bot.w3, [s.functions.keeper() for s in strategies], allow_failure=True)
|
|
217
240
|
```
|
|
218
241
|
|
|
242
|
+
- `allow_failure=False` (default) — any reverting call reverts the whole batch
|
|
243
|
+
- `allow_failure=True` — a reverting call, empty return data, or undecodable data yields `None`
|
|
244
|
+
- `batch_size=200` — calls are split into chunks of this size to stay under RPC `eth_call` gas caps
|
|
245
|
+
- Functions returning tuples/structs are decoded
|
|
246
|
+
|
|
219
247
|
---
|
|
220
248
|
|
|
221
249
|
### `notify_group_chat(text, parse_mode="HTML", chat_id=GROUP_CHAT_ID)`
|
|
222
250
|
|
|
223
|
-
Send a Telegram message. HTML parse mode by default.
|
|
251
|
+
Send a Telegram message. HTML parse mode by default. Does nothing when `BOT_ACCESS_TOKEN` or the chat id is unset.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
### `telegram_enabled() -> bool`
|
|
256
|
+
|
|
257
|
+
`True` when `BOT_ACCESS_TOKEN` is set. Use it to skip work whose only output is a Telegram message.
|
|
224
258
|
|
|
225
259
|
---
|
|
226
260
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|