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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinybot-eth
3
- Version: 0.5.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` | 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 |
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 hex string
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` | 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 |
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 hex string
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.5.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 tinybot.types import EventHandler, EventListener, PeriodicTask, TaskHandler
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.executor = Executor(self.w3, private_key) if private_key else None
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 _poll_task(self, task: PeriodicTask) -> None:
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._poll_task(task)
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(self._account.address),
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
- tx_hash = self._w3.eth.send_raw_transaction(signed.raw_transaction)
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.hex()
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 call, (_, data) in zip(calls, results):
43
- types = [o["type"] for o in call.abi["outputs"]]
44
- result = decode_abi(types, data)
45
- decoded.append(result[0] if len(result) == 1 else result)
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
- 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"))
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(
@@ -44,3 +44,11 @@ class PeriodicTask:
44
44
  handler: TaskHandler
45
45
  notify_errors: bool = True
46
46
  _last_run: float = 0
47
+
48
+
49
+ @dataclass
50
+ class CronTask:
51
+ name: str
52
+ expression: str
53
+ handler: TaskHandler
54
+ notify_errors: bool = True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinybot-eth
3
- Version: 0.5.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` | 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 |
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 hex string
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
 
@@ -2,3 +2,4 @@ web3==7.14.1
2
2
  eth-abi==5.2.0
3
3
  eth-account==0.13.7
4
4
  python-telegram-bot==22.7
5
+ croniter==6.2.2
File without changes