tinybot-eth 0.1.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.1.0/PKG-INFO +234 -0
- tinybot_eth-0.1.0/README.md +223 -0
- tinybot_eth-0.1.0/pyproject.toml +31 -0
- tinybot_eth-0.1.0/setup.cfg +4 -0
- tinybot_eth-0.1.0/tinybot/__init__.py +14 -0
- tinybot_eth-0.1.0/tinybot/bot.py +173 -0
- tinybot_eth-0.1.0/tinybot/executor.py +37 -0
- tinybot_eth-0.1.0/tinybot/multicall.py +32 -0
- tinybot_eth-0.1.0/tinybot/state.py +20 -0
- tinybot_eth-0.1.0/tinybot/tg.py +33 -0
- tinybot_eth-0.1.0/tinybot/types.py +46 -0
- tinybot_eth-0.1.0/tinybot/utils.py +22 -0
- tinybot_eth-0.1.0/tinybot_eth.egg-info/PKG-INFO +234 -0
- tinybot_eth-0.1.0/tinybot_eth.egg-info/SOURCES.txt +15 -0
- tinybot_eth-0.1.0/tinybot_eth.egg-info/dependency_links.txt +1 -0
- tinybot_eth-0.1.0/tinybot_eth.egg-info/requires.txt +4 -0
- tinybot_eth-0.1.0/tinybot_eth.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tinybot-eth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Minimal Python framework for building crypto bots
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: web3==7.14.1
|
|
8
|
+
Requires-Dist: eth-abi==5.2.0
|
|
9
|
+
Requires-Dist: eth-account==0.13.7
|
|
10
|
+
Requires-Dist: python-telegram-bot==22.7
|
|
11
|
+
|
|
12
|
+
# tinybot
|
|
13
|
+
|
|
14
|
+
Minimal Python framework for building crypto bots.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install tinybot-eth
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Environment Variables
|
|
23
|
+
|
|
24
|
+
| Variable | Required | Description |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `BOT_ACCESS_TOKEN` | Yes | Telegram bot token |
|
|
27
|
+
| `GROUP_CHAT_ID` | Yes | Telegram group for notifications |
|
|
28
|
+
| `DEV_GROUP_CHAT_ID` | Yes | Telegram group for errors and startup |
|
|
29
|
+
| `PRIVATE_KEY` | No | Private key for onchain execution |
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import asyncio
|
|
35
|
+
import os
|
|
36
|
+
from tinybot import TinyBot, multicall, notify_group_chat
|
|
37
|
+
|
|
38
|
+
ERC20_ABI = [...]
|
|
39
|
+
STRATEGY_ABI = [...]
|
|
40
|
+
|
|
41
|
+
async def on_transfer(bot, log):
|
|
42
|
+
print(f"{log.args.sender} -> {log.args.receiver}: {log.args.value}")
|
|
43
|
+
await notify_group_chat(f"Transfer from {log.args.sender}")
|
|
44
|
+
|
|
45
|
+
async def check_and_tend(bot):
|
|
46
|
+
strategy = bot.w3.eth.contract(address="0x...", abi=STRATEGY_ABI)
|
|
47
|
+
needs_tend, _ = strategy.functions.tendTrigger().call()
|
|
48
|
+
if needs_tend:
|
|
49
|
+
tx_hash = bot.executor.execute(
|
|
50
|
+
strategy.functions.tend(),
|
|
51
|
+
gas_limit=5_000_000,
|
|
52
|
+
)
|
|
53
|
+
await notify_group_chat(f"Tend submitted: {tx_hash}")
|
|
54
|
+
|
|
55
|
+
async def main():
|
|
56
|
+
bot = TinyBot(
|
|
57
|
+
rpc_url=os.environ["RPC_URL"],
|
|
58
|
+
name="my bot",
|
|
59
|
+
private_key=os.environ.get("PRIVATE_KEY", ""),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
bot.listen(
|
|
63
|
+
name="transfers",
|
|
64
|
+
event="Transfer",
|
|
65
|
+
addresses=["0x..."],
|
|
66
|
+
abi=ERC20_ABI,
|
|
67
|
+
handler=on_transfer,
|
|
68
|
+
poll_interval=180,
|
|
69
|
+
notify_errors=True,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
bot.every(3600, check_and_tend, notify_errors=True)
|
|
73
|
+
|
|
74
|
+
await bot.run()
|
|
75
|
+
|
|
76
|
+
asyncio.run(main())
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
### `TinyBot(rpc_url, name="tinybot", private_key="")`
|
|
82
|
+
|
|
83
|
+
Creates a bot instance.
|
|
84
|
+
|
|
85
|
+
- `bot.w3` — `web3.Web3` instance
|
|
86
|
+
- `bot.state` — `State` instance (see below)
|
|
87
|
+
- `bot.executor` — `Executor` instance if `private_key` is provided, else `None`
|
|
88
|
+
- `bot.name` — used in logs and Telegram startup message
|
|
89
|
+
|
|
90
|
+
On `run()`, sends a startup message to `DEV_GROUP_CHAT_ID` and prints a polling heartbeat every tick.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
### `bot.listen(...) -> EventListener`
|
|
95
|
+
|
|
96
|
+
Register an event listener.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
bot.listen(
|
|
100
|
+
name="kicks", # unique name
|
|
101
|
+
event="AuctionKicked", # event name (must exist in ABI)
|
|
102
|
+
addresses=["0x..."], # contracts to monitor
|
|
103
|
+
abi=[...], # ABI containing the event
|
|
104
|
+
handler=on_kick, # async fn(bot, log)
|
|
105
|
+
poll_interval=180, # seconds between polls (default: 180)
|
|
106
|
+
block_buffer=5, # re-scan buffer in blocks (default: 5)
|
|
107
|
+
notify_errors=True, # send errors to Telegram (default: False)
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The event signature is derived from the ABI at registration time. Raises `ValueError` if:
|
|
112
|
+
- Event not found in ABI
|
|
113
|
+
- Duplicate listener name
|
|
114
|
+
- Empty addresses
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
### `bot.every(interval, handler, name="", notify_errors=False) -> PeriodicTask`
|
|
119
|
+
|
|
120
|
+
Register a periodic task.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
bot.every(3600, check_expired, notify_errors=True)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Handler signature: `async fn(bot)`
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
### `bot.get_listener(name) -> EventListener`
|
|
131
|
+
|
|
132
|
+
Get a registered listener by name. Raises `ValueError` if not found.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
### `bot.replay(name, from_block, to_block)`
|
|
137
|
+
|
|
138
|
+
Replay historical events through a listener's handler. Useful for testing with real chain data.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
await bot.replay("kicks", from_block=21000000, to_block=21000500)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
### `bot.run(tick=10)`
|
|
147
|
+
|
|
148
|
+
Start the polling loop. `tick` (default: 10s) is the inner loop sleep. Each listener and task fires at its own interval.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
### `EventListener`
|
|
153
|
+
|
|
154
|
+
Returned by `bot.listen()`.
|
|
155
|
+
|
|
156
|
+
- `listener.add_address(address)` — add a contract address at runtime
|
|
157
|
+
- `listener.remove_address(address)` — remove a contract address at runtime
|
|
158
|
+
|
|
159
|
+
Both handle checksumming and dedup.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
### `Executor`
|
|
164
|
+
|
|
165
|
+
Available via `bot.executor` when `private_key` is provided.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
bot = TinyBot(rpc_url, name="my bot", private_key=os.environ["PRIVATE_KEY"])
|
|
169
|
+
|
|
170
|
+
tx_hash = bot.executor.execute(
|
|
171
|
+
contract.functions.tend(strategy_addr),
|
|
172
|
+
gas_limit=5_000_000,
|
|
173
|
+
max_fee_gwei=100,
|
|
174
|
+
max_priority_fee_gwei=3,
|
|
175
|
+
)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
- `executor.address` — signer address
|
|
179
|
+
- `executor.balance` — signer ETH balance in wei
|
|
180
|
+
- `executor.execute(call, ...)` — sign and broadcast a transaction, returns tx hash immediately (fire and forget)
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
### `State`
|
|
185
|
+
|
|
186
|
+
In-memory state, available via `bot.state`.
|
|
187
|
+
|
|
188
|
+
- `state.last_block` — `dict[str, int]` mapping names to last processed block
|
|
189
|
+
- `state.active_items` — `list` of tracked items (e.g. address pairs)
|
|
190
|
+
- `state.add_item(*addrs)` — add an item (deduped)
|
|
191
|
+
- `state.remove_item(item)` — remove an item
|
|
192
|
+
- `state.is_processed(event_id)` — check if event was processed
|
|
193
|
+
- `state.mark_processed(event_id)` — mark event as processed (handled automatically for listeners)
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
### `multicall(w3, calls) -> list`
|
|
198
|
+
|
|
199
|
+
Batch contract reads via [Multicall3](https://github.com/mds1/multicall).
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
symbol, decimals = multicall(bot.w3, [
|
|
203
|
+
token.functions.symbol(),
|
|
204
|
+
token.functions.decimals(),
|
|
205
|
+
])
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
### `notify_group_chat(text, parse_mode="HTML", chat_id=GROUP_CHAT_ID)`
|
|
211
|
+
|
|
212
|
+
Send a Telegram message. HTML parse mode by default.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
### `event_id(log) -> str`
|
|
217
|
+
|
|
218
|
+
Unique ID from a log (`txHash:logIndex`). Used internally for dedup, also available for custom event processing in periodic tasks.
|
|
219
|
+
|
|
220
|
+
## Handler Signatures
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
# Event handler
|
|
224
|
+
async def on_event(bot: TinyBot, log) -> None: ...
|
|
225
|
+
|
|
226
|
+
# Task handler
|
|
227
|
+
async def my_task(bot: TinyBot) -> None: ...
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Access `bot.w3`, `bot.state`, `bot.executor`, and `bot.get_listener()` from any handler.
|
|
231
|
+
|
|
232
|
+
## Error Handling
|
|
233
|
+
|
|
234
|
+
When `notify_errors=True`, exceptions are caught and sent to `DEV_GROUP_CHAT_ID` as `[name] error message`. The bot continues running.
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# tinybot
|
|
2
|
+
|
|
3
|
+
Minimal Python framework for building crypto bots.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install tinybot-eth
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Environment Variables
|
|
12
|
+
|
|
13
|
+
| Variable | Required | Description |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `BOT_ACCESS_TOKEN` | Yes | Telegram bot token |
|
|
16
|
+
| `GROUP_CHAT_ID` | Yes | Telegram group for notifications |
|
|
17
|
+
| `DEV_GROUP_CHAT_ID` | Yes | Telegram group for errors and startup |
|
|
18
|
+
| `PRIVATE_KEY` | No | Private key for onchain execution |
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import asyncio
|
|
24
|
+
import os
|
|
25
|
+
from tinybot import TinyBot, multicall, notify_group_chat
|
|
26
|
+
|
|
27
|
+
ERC20_ABI = [...]
|
|
28
|
+
STRATEGY_ABI = [...]
|
|
29
|
+
|
|
30
|
+
async def on_transfer(bot, log):
|
|
31
|
+
print(f"{log.args.sender} -> {log.args.receiver}: {log.args.value}")
|
|
32
|
+
await notify_group_chat(f"Transfer from {log.args.sender}")
|
|
33
|
+
|
|
34
|
+
async def check_and_tend(bot):
|
|
35
|
+
strategy = bot.w3.eth.contract(address="0x...", abi=STRATEGY_ABI)
|
|
36
|
+
needs_tend, _ = strategy.functions.tendTrigger().call()
|
|
37
|
+
if needs_tend:
|
|
38
|
+
tx_hash = bot.executor.execute(
|
|
39
|
+
strategy.functions.tend(),
|
|
40
|
+
gas_limit=5_000_000,
|
|
41
|
+
)
|
|
42
|
+
await notify_group_chat(f"Tend submitted: {tx_hash}")
|
|
43
|
+
|
|
44
|
+
async def main():
|
|
45
|
+
bot = TinyBot(
|
|
46
|
+
rpc_url=os.environ["RPC_URL"],
|
|
47
|
+
name="my bot",
|
|
48
|
+
private_key=os.environ.get("PRIVATE_KEY", ""),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
bot.listen(
|
|
52
|
+
name="transfers",
|
|
53
|
+
event="Transfer",
|
|
54
|
+
addresses=["0x..."],
|
|
55
|
+
abi=ERC20_ABI,
|
|
56
|
+
handler=on_transfer,
|
|
57
|
+
poll_interval=180,
|
|
58
|
+
notify_errors=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
bot.every(3600, check_and_tend, notify_errors=True)
|
|
62
|
+
|
|
63
|
+
await bot.run()
|
|
64
|
+
|
|
65
|
+
asyncio.run(main())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## API
|
|
69
|
+
|
|
70
|
+
### `TinyBot(rpc_url, name="tinybot", private_key="")`
|
|
71
|
+
|
|
72
|
+
Creates a bot instance.
|
|
73
|
+
|
|
74
|
+
- `bot.w3` — `web3.Web3` instance
|
|
75
|
+
- `bot.state` — `State` instance (see below)
|
|
76
|
+
- `bot.executor` — `Executor` instance if `private_key` is provided, else `None`
|
|
77
|
+
- `bot.name` — used in logs and Telegram startup message
|
|
78
|
+
|
|
79
|
+
On `run()`, sends a startup message to `DEV_GROUP_CHAT_ID` and prints a polling heartbeat every tick.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### `bot.listen(...) -> EventListener`
|
|
84
|
+
|
|
85
|
+
Register an event listener.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
bot.listen(
|
|
89
|
+
name="kicks", # unique name
|
|
90
|
+
event="AuctionKicked", # event name (must exist in ABI)
|
|
91
|
+
addresses=["0x..."], # contracts to monitor
|
|
92
|
+
abi=[...], # ABI containing the event
|
|
93
|
+
handler=on_kick, # async fn(bot, log)
|
|
94
|
+
poll_interval=180, # seconds between polls (default: 180)
|
|
95
|
+
block_buffer=5, # re-scan buffer in blocks (default: 5)
|
|
96
|
+
notify_errors=True, # send errors to Telegram (default: False)
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The event signature is derived from the ABI at registration time. Raises `ValueError` if:
|
|
101
|
+
- Event not found in ABI
|
|
102
|
+
- Duplicate listener name
|
|
103
|
+
- Empty addresses
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
### `bot.every(interval, handler, name="", notify_errors=False) -> PeriodicTask`
|
|
108
|
+
|
|
109
|
+
Register a periodic task.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
bot.every(3600, check_expired, notify_errors=True)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Handler signature: `async fn(bot)`
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
### `bot.get_listener(name) -> EventListener`
|
|
120
|
+
|
|
121
|
+
Get a registered listener by name. Raises `ValueError` if not found.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
### `bot.replay(name, from_block, to_block)`
|
|
126
|
+
|
|
127
|
+
Replay historical events through a listener's handler. Useful for testing with real chain data.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
await bot.replay("kicks", from_block=21000000, to_block=21000500)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
### `bot.run(tick=10)`
|
|
136
|
+
|
|
137
|
+
Start the polling loop. `tick` (default: 10s) is the inner loop sleep. Each listener and task fires at its own interval.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
### `EventListener`
|
|
142
|
+
|
|
143
|
+
Returned by `bot.listen()`.
|
|
144
|
+
|
|
145
|
+
- `listener.add_address(address)` — add a contract address at runtime
|
|
146
|
+
- `listener.remove_address(address)` — remove a contract address at runtime
|
|
147
|
+
|
|
148
|
+
Both handle checksumming and dedup.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
### `Executor`
|
|
153
|
+
|
|
154
|
+
Available via `bot.executor` when `private_key` is provided.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
bot = TinyBot(rpc_url, name="my bot", private_key=os.environ["PRIVATE_KEY"])
|
|
158
|
+
|
|
159
|
+
tx_hash = bot.executor.execute(
|
|
160
|
+
contract.functions.tend(strategy_addr),
|
|
161
|
+
gas_limit=5_000_000,
|
|
162
|
+
max_fee_gwei=100,
|
|
163
|
+
max_priority_fee_gwei=3,
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- `executor.address` — signer address
|
|
168
|
+
- `executor.balance` — signer ETH balance in wei
|
|
169
|
+
- `executor.execute(call, ...)` — sign and broadcast a transaction, returns tx hash immediately (fire and forget)
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
### `State`
|
|
174
|
+
|
|
175
|
+
In-memory state, available via `bot.state`.
|
|
176
|
+
|
|
177
|
+
- `state.last_block` — `dict[str, int]` mapping names to last processed block
|
|
178
|
+
- `state.active_items` — `list` of tracked items (e.g. address pairs)
|
|
179
|
+
- `state.add_item(*addrs)` — add an item (deduped)
|
|
180
|
+
- `state.remove_item(item)` — remove an item
|
|
181
|
+
- `state.is_processed(event_id)` — check if event was processed
|
|
182
|
+
- `state.mark_processed(event_id)` — mark event as processed (handled automatically for listeners)
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
### `multicall(w3, calls) -> list`
|
|
187
|
+
|
|
188
|
+
Batch contract reads via [Multicall3](https://github.com/mds1/multicall).
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
symbol, decimals = multicall(bot.w3, [
|
|
192
|
+
token.functions.symbol(),
|
|
193
|
+
token.functions.decimals(),
|
|
194
|
+
])
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
### `notify_group_chat(text, parse_mode="HTML", chat_id=GROUP_CHAT_ID)`
|
|
200
|
+
|
|
201
|
+
Send a Telegram message. HTML parse mode by default.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
### `event_id(log) -> str`
|
|
206
|
+
|
|
207
|
+
Unique ID from a log (`txHash:logIndex`). Used internally for dedup, also available for custom event processing in periodic tasks.
|
|
208
|
+
|
|
209
|
+
## Handler Signatures
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
# Event handler
|
|
213
|
+
async def on_event(bot: TinyBot, log) -> None: ...
|
|
214
|
+
|
|
215
|
+
# Task handler
|
|
216
|
+
async def my_task(bot: TinyBot) -> None: ...
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Access `bot.w3`, `bot.state`, `bot.executor`, and `bot.get_listener()` from any handler.
|
|
220
|
+
|
|
221
|
+
## Error Handling
|
|
222
|
+
|
|
223
|
+
When `notify_errors=True`, exceptions are caught and sent to `DEV_GROUP_CHAT_ID` as `[name] error message`. The bot continues running.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tinybot-eth"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Minimal Python framework for building crypto bots"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"web3==7.14.1",
|
|
9
|
+
"eth-abi==5.2.0",
|
|
10
|
+
"eth-account==0.13.7",
|
|
11
|
+
"python-telegram-bot==22.7",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.setuptools.packages.find]
|
|
15
|
+
where = ["."]
|
|
16
|
+
include = ["tinybot*"]
|
|
17
|
+
|
|
18
|
+
[tool.ruff]
|
|
19
|
+
line-length = 120
|
|
20
|
+
target-version = "py312"
|
|
21
|
+
lint.select = ["E", "F", "I", "W"]
|
|
22
|
+
fix = true
|
|
23
|
+
lint.ignore = ['E501']
|
|
24
|
+
|
|
25
|
+
[tool.ruff.format]
|
|
26
|
+
quote-style = "double"
|
|
27
|
+
docstring-code-format = true
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["setuptools", "wheel"]
|
|
31
|
+
build-backend = "setuptools.build_meta"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from tinybot.bot import TinyBot
|
|
2
|
+
from tinybot.executor import Executor
|
|
3
|
+
from tinybot.multicall import multicall
|
|
4
|
+
from tinybot.tg import DEV_GROUP_CHAT_ID, notify_group_chat
|
|
5
|
+
from tinybot.utils import event_id
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Executor",
|
|
9
|
+
"TinyBot",
|
|
10
|
+
"DEV_GROUP_CHAT_ID",
|
|
11
|
+
"event_id",
|
|
12
|
+
"multicall",
|
|
13
|
+
"notify_group_chat",
|
|
14
|
+
]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from web3 import Web3
|
|
7
|
+
|
|
8
|
+
from tinybot.executor import Executor
|
|
9
|
+
from tinybot.state import State
|
|
10
|
+
from tinybot.tg import DEV_GROUP_CHAT_ID, notify_group_chat
|
|
11
|
+
from tinybot.types import EventHandler, EventListener, PeriodicTask, TaskHandler
|
|
12
|
+
from tinybot.utils import event_id, event_signature
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TinyBot:
|
|
16
|
+
def __init__(self, rpc_url: str, name: str = "tinybot", private_key: str = ""):
|
|
17
|
+
self.w3 = Web3(Web3.HTTPProvider(rpc_url))
|
|
18
|
+
self.name = name
|
|
19
|
+
self.state = State()
|
|
20
|
+
self.executor = Executor(self.w3, private_key) if private_key else None
|
|
21
|
+
self._listeners: list[EventListener] = []
|
|
22
|
+
self._tasks: list[PeriodicTask] = []
|
|
23
|
+
|
|
24
|
+
# -------------------------------------------------------------------------
|
|
25
|
+
# Registration
|
|
26
|
+
# -------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def listen(
|
|
29
|
+
self,
|
|
30
|
+
name: str,
|
|
31
|
+
event: str,
|
|
32
|
+
addresses: list[str],
|
|
33
|
+
abi: list[dict[str, Any]],
|
|
34
|
+
handler: EventHandler,
|
|
35
|
+
poll_interval: int = 180,
|
|
36
|
+
block_buffer: int = 5,
|
|
37
|
+
notify_errors: bool = False,
|
|
38
|
+
) -> EventListener:
|
|
39
|
+
if not addresses:
|
|
40
|
+
raise ValueError(f"listener '{name}': addresses cannot be empty")
|
|
41
|
+
if any(l.name == name for l in self._listeners):
|
|
42
|
+
raise ValueError(f"listener '{name}' already registered")
|
|
43
|
+
|
|
44
|
+
signature = event_signature(abi, event)
|
|
45
|
+
|
|
46
|
+
listener = EventListener(
|
|
47
|
+
name=name,
|
|
48
|
+
signature=signature,
|
|
49
|
+
addresses=[self.w3.to_checksum_address(a) for a in addresses],
|
|
50
|
+
abi=abi,
|
|
51
|
+
handler=handler,
|
|
52
|
+
poll_interval=poll_interval,
|
|
53
|
+
block_buffer=block_buffer,
|
|
54
|
+
notify_errors=notify_errors,
|
|
55
|
+
_w3=self.w3,
|
|
56
|
+
)
|
|
57
|
+
self._listeners.append(listener)
|
|
58
|
+
return listener
|
|
59
|
+
|
|
60
|
+
def every(
|
|
61
|
+
self,
|
|
62
|
+
interval: int,
|
|
63
|
+
handler: TaskHandler,
|
|
64
|
+
name: str = "",
|
|
65
|
+
notify_errors: bool = False,
|
|
66
|
+
) -> PeriodicTask:
|
|
67
|
+
task = PeriodicTask(
|
|
68
|
+
name=name or handler.__name__,
|
|
69
|
+
interval=interval,
|
|
70
|
+
handler=handler,
|
|
71
|
+
notify_errors=notify_errors,
|
|
72
|
+
)
|
|
73
|
+
self._tasks.append(task)
|
|
74
|
+
return task
|
|
75
|
+
|
|
76
|
+
# -------------------------------------------------------------------------
|
|
77
|
+
# Getters
|
|
78
|
+
# -------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def get_listener(self, name: str) -> EventListener:
|
|
81
|
+
for l in self._listeners:
|
|
82
|
+
if l.name == name:
|
|
83
|
+
return l
|
|
84
|
+
raise ValueError(f"listener '{name}' not found")
|
|
85
|
+
|
|
86
|
+
# -------------------------------------------------------------------------
|
|
87
|
+
# Replay
|
|
88
|
+
# -------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
async def replay(self, name: str, from_block: int, to_block: int) -> None:
|
|
91
|
+
listener = self.get_listener(name)
|
|
92
|
+
print(f"[{self.name}] replaying '{name}' from {from_block} to {to_block}...")
|
|
93
|
+
await self._process_logs(listener, from_block, to_block)
|
|
94
|
+
print(f"[{self.name}] replay '{name}' done")
|
|
95
|
+
|
|
96
|
+
# -------------------------------------------------------------------------
|
|
97
|
+
# Polling
|
|
98
|
+
# -------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
async def _handle_error(self, e: Exception, name: str, notify: bool) -> None:
|
|
101
|
+
print(f"[{name}] error: {e}")
|
|
102
|
+
if notify:
|
|
103
|
+
await notify_group_chat(f"❌ [{name}] {e}", chat_id=DEV_GROUP_CHAT_ID)
|
|
104
|
+
|
|
105
|
+
async def _process_logs(self, listener: EventListener, from_block: int, to_block: int) -> None:
|
|
106
|
+
topic = self.w3.keccak(text=listener.signature)
|
|
107
|
+
decoder = self.w3.eth.contract(address=listener.addresses[0], abi=listener.abi)
|
|
108
|
+
event_name = listener.signature.split("(")[0]
|
|
109
|
+
|
|
110
|
+
raw_logs = self.w3.eth.get_logs({
|
|
111
|
+
"fromBlock": from_block,
|
|
112
|
+
"toBlock": to_block,
|
|
113
|
+
"address": listener.addresses,
|
|
114
|
+
"topics": [topic],
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
for raw_log in raw_logs:
|
|
118
|
+
event = getattr(decoder.events, event_name)()
|
|
119
|
+
log = event.process_log(raw_log)
|
|
120
|
+
eid = event_id(log)
|
|
121
|
+
if self.state.is_processed(eid):
|
|
122
|
+
continue
|
|
123
|
+
await listener.handler(self, log)
|
|
124
|
+
self.state.mark_processed(eid)
|
|
125
|
+
|
|
126
|
+
async def _poll_listener(self, listener: EventListener) -> None:
|
|
127
|
+
now = time.time()
|
|
128
|
+
if now - listener._last_run < listener.poll_interval:
|
|
129
|
+
return
|
|
130
|
+
listener._last_run = now
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
current_block: int = self.w3.eth.block_number
|
|
134
|
+
last = self.state.last_block.get(listener.name, 0)
|
|
135
|
+
from_block = last - listener.block_buffer if last else current_block
|
|
136
|
+
|
|
137
|
+
if from_block >= current_block:
|
|
138
|
+
self.state.last_block[listener.name] = current_block
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
await self._process_logs(listener, from_block, current_block)
|
|
142
|
+
self.state.last_block[listener.name] = current_block
|
|
143
|
+
except Exception as e:
|
|
144
|
+
await self._handle_error(e, listener.name, listener.notify_errors)
|
|
145
|
+
|
|
146
|
+
async def _poll_task(self, task: PeriodicTask) -> None:
|
|
147
|
+
now = time.time()
|
|
148
|
+
if now - task._last_run < task.interval:
|
|
149
|
+
return
|
|
150
|
+
task._last_run = now
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
await task.handler(self)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
await self._handle_error(e, task.name, task.notify_errors)
|
|
156
|
+
|
|
157
|
+
# -------------------------------------------------------------------------
|
|
158
|
+
# Run
|
|
159
|
+
# -------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
async def run(self, tick: int = 10) -> None:
|
|
162
|
+
await notify_group_chat(
|
|
163
|
+
f"🟢 <b>{self.name} started</b>",
|
|
164
|
+
chat_id=DEV_GROUP_CHAT_ID,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
while True:
|
|
168
|
+
print(f"[{self.name}] polling... {datetime.now()}")
|
|
169
|
+
for listener in self._listeners:
|
|
170
|
+
await self._poll_listener(listener)
|
|
171
|
+
for task in self._tasks:
|
|
172
|
+
await self._poll_task(task)
|
|
173
|
+
await asyncio.sleep(tick)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from web3 import Web3
|
|
2
|
+
from eth_account import Account
|
|
3
|
+
from eth_account.signers.local import LocalAccount
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Executor:
|
|
7
|
+
def __init__(self, w3: Web3, private_key: str):
|
|
8
|
+
self._w3 = w3
|
|
9
|
+
self._account: LocalAccount = Account.from_key(private_key)
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def address(self) -> str:
|
|
13
|
+
return self._account.address
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def balance(self) -> int:
|
|
17
|
+
return self._w3.eth.get_balance(self._account.address)
|
|
18
|
+
|
|
19
|
+
def execute(
|
|
20
|
+
self,
|
|
21
|
+
call,
|
|
22
|
+
gas_limit: int = 500_000,
|
|
23
|
+
max_fee_gwei: int = 100,
|
|
24
|
+
max_priority_fee_gwei: int = 3,
|
|
25
|
+
value: int = 0,
|
|
26
|
+
) -> str:
|
|
27
|
+
tx = call.build_transaction({
|
|
28
|
+
"from": self._account.address,
|
|
29
|
+
"nonce": self._w3.eth.get_transaction_count(self._account.address),
|
|
30
|
+
"gas": gas_limit,
|
|
31
|
+
"maxFeePerGas": self._w3.to_wei(max_fee_gwei, "gwei"),
|
|
32
|
+
"maxPriorityFeePerGas": self._w3.to_wei(max_priority_fee_gwei, "gwei"),
|
|
33
|
+
"value": value,
|
|
34
|
+
})
|
|
35
|
+
signed = self._w3.eth.account.sign_transaction(tx, self._account.key)
|
|
36
|
+
tx_hash = self._w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
37
|
+
return tx_hash.hex()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from eth_abi import decode as decode_abi
|
|
2
|
+
from web3 import Web3
|
|
3
|
+
|
|
4
|
+
# Multicall3 — same address on all chains
|
|
5
|
+
MULTICALL3 = Web3.to_checksum_address("0xcA11bde05977b3631167028862bE2a173976CA11")
|
|
6
|
+
MULTICALL3_ABI = [{
|
|
7
|
+
"name": "aggregate3",
|
|
8
|
+
"type": "function",
|
|
9
|
+
"stateMutability": "payable",
|
|
10
|
+
"inputs": [{"name": "calls", "type": "tuple[]", "components": [
|
|
11
|
+
{"name": "target", "type": "address"},
|
|
12
|
+
{"name": "allowFailure", "type": "bool"},
|
|
13
|
+
{"name": "callData", "type": "bytes"},
|
|
14
|
+
]}],
|
|
15
|
+
"outputs": [{"name": "returnData", "type": "tuple[]", "components": [
|
|
16
|
+
{"name": "success", "type": "bool"},
|
|
17
|
+
{"name": "returnData", "type": "bytes"},
|
|
18
|
+
]}],
|
|
19
|
+
}]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def multicall(w3: Web3, calls: list) -> list:
|
|
23
|
+
"""Batch contract calls. calls = list of ContractFunction objects."""
|
|
24
|
+
mc = w3.eth.contract(address=MULTICALL3, abi=MULTICALL3_ABI)
|
|
25
|
+
encoded = [(call.address, False, call._encode_transaction_data()) for call in calls]
|
|
26
|
+
results = mc.functions.aggregate3(encoded).call()
|
|
27
|
+
decoded = []
|
|
28
|
+
for call, (_, data) in zip(calls, results):
|
|
29
|
+
types = [o["type"] for o in call.abi["outputs"]]
|
|
30
|
+
result = decode_abi(types, data)
|
|
31
|
+
decoded.append(result[0] if len(result) == 1 else result)
|
|
32
|
+
return decoded
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class State:
|
|
2
|
+
def __init__(self):
|
|
3
|
+
self.last_block: dict[str, int] = {} # listener_name -> block
|
|
4
|
+
self.active_items: list = []
|
|
5
|
+
self._processed: set = set()
|
|
6
|
+
|
|
7
|
+
def is_processed(self, event_id: str) -> bool:
|
|
8
|
+
return event_id in self._processed
|
|
9
|
+
|
|
10
|
+
def mark_processed(self, event_id: str):
|
|
11
|
+
self._processed.add(event_id)
|
|
12
|
+
|
|
13
|
+
def add_item(self, *addrs: str):
|
|
14
|
+
item = list(addrs)
|
|
15
|
+
if item not in self.active_items:
|
|
16
|
+
self.active_items.append(item)
|
|
17
|
+
|
|
18
|
+
def remove_item(self, item: list):
|
|
19
|
+
if item in self.active_items:
|
|
20
|
+
self.active_items.remove(item)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from telegram import Bot
|
|
4
|
+
|
|
5
|
+
|
|
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
|
+
|
|
12
|
+
|
|
13
|
+
BOT_ACCESS_TOKEN = _require_env("BOT_ACCESS_TOKEN")
|
|
14
|
+
GROUP_CHAT_ID = int(_require_env("GROUP_CHAT_ID"))
|
|
15
|
+
DEV_GROUP_CHAT_ID = int(_require_env("DEV_GROUP_CHAT_ID"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def notify_group_chat(
|
|
19
|
+
text: str,
|
|
20
|
+
parse_mode: str = "HTML",
|
|
21
|
+
chat_id: int = GROUP_CHAT_ID,
|
|
22
|
+
disable_web_page_preview: bool = True,
|
|
23
|
+
) -> None:
|
|
24
|
+
try:
|
|
25
|
+
bot = Bot(token=BOT_ACCESS_TOKEN)
|
|
26
|
+
await bot.send_message(
|
|
27
|
+
chat_id=chat_id,
|
|
28
|
+
text=text,
|
|
29
|
+
parse_mode=parse_mode,
|
|
30
|
+
disable_web_page_preview=disable_web_page_preview,
|
|
31
|
+
)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
print(f"Failed to send message to group chat: {e}")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from web3 import Web3
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from tinybot.bot import TinyBot
|
|
11
|
+
|
|
12
|
+
EventHandler = Callable[["TinyBot", Any], Awaitable[None]]
|
|
13
|
+
TaskHandler = Callable[["TinyBot"], Awaitable[None]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class EventListener:
|
|
18
|
+
name: str
|
|
19
|
+
signature: str
|
|
20
|
+
addresses: list[str]
|
|
21
|
+
abi: list[dict[str, Any]]
|
|
22
|
+
handler: EventHandler
|
|
23
|
+
poll_interval: int = 180
|
|
24
|
+
block_buffer: int = 5
|
|
25
|
+
notify_errors: bool = False
|
|
26
|
+
_last_run: float = 0
|
|
27
|
+
_w3: Web3 | None = None
|
|
28
|
+
|
|
29
|
+
def add_address(self, address: str) -> None:
|
|
30
|
+
checksummed = self._w3.to_checksum_address(address) # type: ignore[union-attr]
|
|
31
|
+
if checksummed not in self.addresses:
|
|
32
|
+
self.addresses.append(checksummed)
|
|
33
|
+
|
|
34
|
+
def remove_address(self, address: str) -> None:
|
|
35
|
+
checksummed = self._w3.to_checksum_address(address) # type: ignore[union-attr]
|
|
36
|
+
if checksummed in self.addresses:
|
|
37
|
+
self.addresses.remove(checksummed)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class PeriodicTask:
|
|
42
|
+
name: str
|
|
43
|
+
interval: int
|
|
44
|
+
handler: TaskHandler
|
|
45
|
+
notify_errors: bool = False
|
|
46
|
+
_last_run: float = 0
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def event_signature(abi: list[dict[str, Any]], event_name: str) -> str:
|
|
6
|
+
for item in abi:
|
|
7
|
+
if item.get("type") == "event" and item.get("name") == event_name:
|
|
8
|
+
types = ",".join(inp["type"] for inp in item["inputs"])
|
|
9
|
+
return f"{event_name}({types})"
|
|
10
|
+
raise ValueError(f"event '{event_name}' not found in ABI")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def event_id(log) -> str:
|
|
14
|
+
return f"{log.transactionHash.hex()}:{log.logIndex}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEBUG = os.getenv("DEBUG", False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def debug(msg: str):
|
|
21
|
+
if DEBUG:
|
|
22
|
+
print(f"DEBUG: {msg}")
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tinybot-eth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Minimal Python framework for building crypto bots
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: web3==7.14.1
|
|
8
|
+
Requires-Dist: eth-abi==5.2.0
|
|
9
|
+
Requires-Dist: eth-account==0.13.7
|
|
10
|
+
Requires-Dist: python-telegram-bot==22.7
|
|
11
|
+
|
|
12
|
+
# tinybot
|
|
13
|
+
|
|
14
|
+
Minimal Python framework for building crypto bots.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install tinybot-eth
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Environment Variables
|
|
23
|
+
|
|
24
|
+
| Variable | Required | Description |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `BOT_ACCESS_TOKEN` | Yes | Telegram bot token |
|
|
27
|
+
| `GROUP_CHAT_ID` | Yes | Telegram group for notifications |
|
|
28
|
+
| `DEV_GROUP_CHAT_ID` | Yes | Telegram group for errors and startup |
|
|
29
|
+
| `PRIVATE_KEY` | No | Private key for onchain execution |
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import asyncio
|
|
35
|
+
import os
|
|
36
|
+
from tinybot import TinyBot, multicall, notify_group_chat
|
|
37
|
+
|
|
38
|
+
ERC20_ABI = [...]
|
|
39
|
+
STRATEGY_ABI = [...]
|
|
40
|
+
|
|
41
|
+
async def on_transfer(bot, log):
|
|
42
|
+
print(f"{log.args.sender} -> {log.args.receiver}: {log.args.value}")
|
|
43
|
+
await notify_group_chat(f"Transfer from {log.args.sender}")
|
|
44
|
+
|
|
45
|
+
async def check_and_tend(bot):
|
|
46
|
+
strategy = bot.w3.eth.contract(address="0x...", abi=STRATEGY_ABI)
|
|
47
|
+
needs_tend, _ = strategy.functions.tendTrigger().call()
|
|
48
|
+
if needs_tend:
|
|
49
|
+
tx_hash = bot.executor.execute(
|
|
50
|
+
strategy.functions.tend(),
|
|
51
|
+
gas_limit=5_000_000,
|
|
52
|
+
)
|
|
53
|
+
await notify_group_chat(f"Tend submitted: {tx_hash}")
|
|
54
|
+
|
|
55
|
+
async def main():
|
|
56
|
+
bot = TinyBot(
|
|
57
|
+
rpc_url=os.environ["RPC_URL"],
|
|
58
|
+
name="my bot",
|
|
59
|
+
private_key=os.environ.get("PRIVATE_KEY", ""),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
bot.listen(
|
|
63
|
+
name="transfers",
|
|
64
|
+
event="Transfer",
|
|
65
|
+
addresses=["0x..."],
|
|
66
|
+
abi=ERC20_ABI,
|
|
67
|
+
handler=on_transfer,
|
|
68
|
+
poll_interval=180,
|
|
69
|
+
notify_errors=True,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
bot.every(3600, check_and_tend, notify_errors=True)
|
|
73
|
+
|
|
74
|
+
await bot.run()
|
|
75
|
+
|
|
76
|
+
asyncio.run(main())
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
### `TinyBot(rpc_url, name="tinybot", private_key="")`
|
|
82
|
+
|
|
83
|
+
Creates a bot instance.
|
|
84
|
+
|
|
85
|
+
- `bot.w3` — `web3.Web3` instance
|
|
86
|
+
- `bot.state` — `State` instance (see below)
|
|
87
|
+
- `bot.executor` — `Executor` instance if `private_key` is provided, else `None`
|
|
88
|
+
- `bot.name` — used in logs and Telegram startup message
|
|
89
|
+
|
|
90
|
+
On `run()`, sends a startup message to `DEV_GROUP_CHAT_ID` and prints a polling heartbeat every tick.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
### `bot.listen(...) -> EventListener`
|
|
95
|
+
|
|
96
|
+
Register an event listener.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
bot.listen(
|
|
100
|
+
name="kicks", # unique name
|
|
101
|
+
event="AuctionKicked", # event name (must exist in ABI)
|
|
102
|
+
addresses=["0x..."], # contracts to monitor
|
|
103
|
+
abi=[...], # ABI containing the event
|
|
104
|
+
handler=on_kick, # async fn(bot, log)
|
|
105
|
+
poll_interval=180, # seconds between polls (default: 180)
|
|
106
|
+
block_buffer=5, # re-scan buffer in blocks (default: 5)
|
|
107
|
+
notify_errors=True, # send errors to Telegram (default: False)
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The event signature is derived from the ABI at registration time. Raises `ValueError` if:
|
|
112
|
+
- Event not found in ABI
|
|
113
|
+
- Duplicate listener name
|
|
114
|
+
- Empty addresses
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
### `bot.every(interval, handler, name="", notify_errors=False) -> PeriodicTask`
|
|
119
|
+
|
|
120
|
+
Register a periodic task.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
bot.every(3600, check_expired, notify_errors=True)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Handler signature: `async fn(bot)`
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
### `bot.get_listener(name) -> EventListener`
|
|
131
|
+
|
|
132
|
+
Get a registered listener by name. Raises `ValueError` if not found.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
### `bot.replay(name, from_block, to_block)`
|
|
137
|
+
|
|
138
|
+
Replay historical events through a listener's handler. Useful for testing with real chain data.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
await bot.replay("kicks", from_block=21000000, to_block=21000500)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
### `bot.run(tick=10)`
|
|
147
|
+
|
|
148
|
+
Start the polling loop. `tick` (default: 10s) is the inner loop sleep. Each listener and task fires at its own interval.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
### `EventListener`
|
|
153
|
+
|
|
154
|
+
Returned by `bot.listen()`.
|
|
155
|
+
|
|
156
|
+
- `listener.add_address(address)` — add a contract address at runtime
|
|
157
|
+
- `listener.remove_address(address)` — remove a contract address at runtime
|
|
158
|
+
|
|
159
|
+
Both handle checksumming and dedup.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
### `Executor`
|
|
164
|
+
|
|
165
|
+
Available via `bot.executor` when `private_key` is provided.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
bot = TinyBot(rpc_url, name="my bot", private_key=os.environ["PRIVATE_KEY"])
|
|
169
|
+
|
|
170
|
+
tx_hash = bot.executor.execute(
|
|
171
|
+
contract.functions.tend(strategy_addr),
|
|
172
|
+
gas_limit=5_000_000,
|
|
173
|
+
max_fee_gwei=100,
|
|
174
|
+
max_priority_fee_gwei=3,
|
|
175
|
+
)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
- `executor.address` — signer address
|
|
179
|
+
- `executor.balance` — signer ETH balance in wei
|
|
180
|
+
- `executor.execute(call, ...)` — sign and broadcast a transaction, returns tx hash immediately (fire and forget)
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
### `State`
|
|
185
|
+
|
|
186
|
+
In-memory state, available via `bot.state`.
|
|
187
|
+
|
|
188
|
+
- `state.last_block` — `dict[str, int]` mapping names to last processed block
|
|
189
|
+
- `state.active_items` — `list` of tracked items (e.g. address pairs)
|
|
190
|
+
- `state.add_item(*addrs)` — add an item (deduped)
|
|
191
|
+
- `state.remove_item(item)` — remove an item
|
|
192
|
+
- `state.is_processed(event_id)` — check if event was processed
|
|
193
|
+
- `state.mark_processed(event_id)` — mark event as processed (handled automatically for listeners)
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
### `multicall(w3, calls) -> list`
|
|
198
|
+
|
|
199
|
+
Batch contract reads via [Multicall3](https://github.com/mds1/multicall).
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
symbol, decimals = multicall(bot.w3, [
|
|
203
|
+
token.functions.symbol(),
|
|
204
|
+
token.functions.decimals(),
|
|
205
|
+
])
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
### `notify_group_chat(text, parse_mode="HTML", chat_id=GROUP_CHAT_ID)`
|
|
211
|
+
|
|
212
|
+
Send a Telegram message. HTML parse mode by default.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
### `event_id(log) -> str`
|
|
217
|
+
|
|
218
|
+
Unique ID from a log (`txHash:logIndex`). Used internally for dedup, also available for custom event processing in periodic tasks.
|
|
219
|
+
|
|
220
|
+
## Handler Signatures
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
# Event handler
|
|
224
|
+
async def on_event(bot: TinyBot, log) -> None: ...
|
|
225
|
+
|
|
226
|
+
# Task handler
|
|
227
|
+
async def my_task(bot: TinyBot) -> None: ...
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Access `bot.w3`, `bot.state`, `bot.executor`, and `bot.get_listener()` from any handler.
|
|
231
|
+
|
|
232
|
+
## Error Handling
|
|
233
|
+
|
|
234
|
+
When `notify_errors=True`, exceptions are caught and sent to `DEV_GROUP_CHAT_ID` as `[name] error message`. The bot continues running.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tinybot/__init__.py
|
|
4
|
+
tinybot/bot.py
|
|
5
|
+
tinybot/executor.py
|
|
6
|
+
tinybot/multicall.py
|
|
7
|
+
tinybot/state.py
|
|
8
|
+
tinybot/tg.py
|
|
9
|
+
tinybot/types.py
|
|
10
|
+
tinybot/utils.py
|
|
11
|
+
tinybot_eth.egg-info/PKG-INFO
|
|
12
|
+
tinybot_eth.egg-info/SOURCES.txt
|
|
13
|
+
tinybot_eth.egg-info/dependency_links.txt
|
|
14
|
+
tinybot_eth.egg-info/requires.txt
|
|
15
|
+
tinybot_eth.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tinybot
|