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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ web3==7.14.1
2
+ eth-abi==5.2.0
3
+ eth-account==0.13.7
4
+ python-telegram-bot==22.7
@@ -0,0 +1 @@
1
+ tinybot