tinybot-eth 0.6.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.6.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
@@ -24,9 +24,9 @@ pip install tinybot-eth
24
24
 
25
25
  | Variable | Required | Description |
26
26
  |---|---|---|
27
- | `BOT_ACCESS_TOKEN` | Yes | Telegram bot token |
28
- | `GROUP_CHAT_ID` | Yes | Telegram group for notifications |
29
- | `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 |
30
30
  | `PRIVATE_KEY` | No | Private key for onchain execution |
31
31
 
32
32
  ## Quick Start
@@ -85,7 +85,7 @@ asyncio.run(main())
85
85
 
86
86
  ## API
87
87
 
88
- ### `TinyBot(rpc_url, name="tinybot", private_key="")`
88
+ ### `TinyBot(rpc_url, private_rpc_url="", name="tinybot", private_key="")`
89
89
 
90
90
  Creates a bot instance.
91
91
 
@@ -93,6 +93,7 @@ Creates a bot instance.
93
93
  - `bot.state` — `State` instance (see below)
94
94
  - `bot.executor` — `Executor` instance if `private_key` is provided, else `None`
95
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`
96
97
 
97
98
  On `run()`, sends a startup message to `DEV_GROUP_CHAT_ID` and prints a polling heartbeat every tick.
98
99
 
@@ -194,16 +195,20 @@ tx_hash = bot.executor.execute(
194
195
  max_priority_fee_gwei=0, # default: 0
195
196
  simulate=True, # default: True — dry-run via eth_call before sending
196
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
197
200
  )
198
201
  ```
199
202
 
200
203
  - `executor.address` — signer address
201
204
  - `executor.balance` — signer ETH balance in wei
202
- - `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
203
206
  - `gas_limit=0` (default) — auto-estimates gas with 1.5x buffer; pass a value to override
204
207
  - `max_fee_gwei` / `max_priority_fee_gwei` — accept floats (e.g. `0.1`)
205
208
  - `simulate=True` (default) — runs `call.call()` first; reverts raise before the tx is sent
206
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
207
212
 
208
213
  ---
209
214
 
@@ -220,7 +225,7 @@ In-memory state, available via `bot.state`.
220
225
 
221
226
  ---
222
227
 
223
- ### `multicall(w3, calls) -> list`
228
+ ### `multicall(w3, calls, allow_failure=False, batch_size=200) -> list`
224
229
 
225
230
  Batch contract reads via [Multicall3](https://github.com/mds1/multicall).
226
231
 
@@ -229,13 +234,27 @@ symbol, decimals = multicall(bot.w3, [
229
234
  token.functions.symbol(),
230
235
  token.functions.decimals(),
231
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)
232
240
  ```
233
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
+
234
247
  ---
235
248
 
236
249
  ### `notify_group_chat(text, parse_mode="HTML", chat_id=GROUP_CHAT_ID)`
237
250
 
238
- 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.
239
258
 
240
259
  ---
241
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
@@ -73,7 +73,7 @@ asyncio.run(main())
73
73
 
74
74
  ## API
75
75
 
76
- ### `TinyBot(rpc_url, name="tinybot", private_key="")`
76
+ ### `TinyBot(rpc_url, private_rpc_url="", name="tinybot", private_key="")`
77
77
 
78
78
  Creates a bot instance.
79
79
 
@@ -81,6 +81,7 @@ Creates a bot instance.
81
81
  - `bot.state` — `State` instance (see below)
82
82
  - `bot.executor` — `Executor` instance if `private_key` is provided, else `None`
83
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`
84
85
 
85
86
  On `run()`, sends a startup message to `DEV_GROUP_CHAT_ID` and prints a polling heartbeat every tick.
86
87
 
@@ -182,16 +183,20 @@ tx_hash = bot.executor.execute(
182
183
  max_priority_fee_gwei=0, # default: 0
183
184
  simulate=True, # default: True — dry-run via eth_call before sending
184
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
185
188
  )
186
189
  ```
187
190
 
188
191
  - `executor.address` — signer address
189
192
  - `executor.balance` — signer ETH balance in wei
190
- - `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
191
194
  - `gas_limit=0` (default) — auto-estimates gas with 1.5x buffer; pass a value to override
192
195
  - `max_fee_gwei` / `max_priority_fee_gwei` — accept floats (e.g. `0.1`)
193
196
  - `simulate=True` (default) — runs `call.call()` first; reverts raise before the tx is sent
194
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
195
200
 
196
201
  ---
197
202
 
@@ -208,7 +213,7 @@ In-memory state, available via `bot.state`.
208
213
 
209
214
  ---
210
215
 
211
- ### `multicall(w3, calls) -> list`
216
+ ### `multicall(w3, calls, allow_failure=False, batch_size=200) -> list`
212
217
 
213
218
  Batch contract reads via [Multicall3](https://github.com/mds1/multicall).
214
219
 
@@ -217,13 +222,27 @@ symbol, decimals = multicall(bot.w3, [
217
222
  token.functions.symbol(),
218
223
  token.functions.decimals(),
219
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)
220
228
  ```
221
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
+
222
235
  ---
223
236
 
224
237
  ### `notify_group_chat(text, parse_mode="HTML", chat_id=GROUP_CHAT_ID)`
225
238
 
226
- 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.
227
246
 
228
247
  ---
229
248
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tinybot-eth"
3
- version = "0.6.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"
@@ -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
  ]
@@ -15,11 +15,12 @@ from tinybot.utils import event_id, event_signature
15
15
 
16
16
 
17
17
  class TinyBot:
18
- def __init__(self, rpc_url: str, name: str = "tinybot", private_key: str = ""):
19
- 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"}))
20
20
  self.name = name
21
21
  self.state = State()
22
- 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
23
24
  self._listeners: list[EventListener] = []
24
25
  self._tasks: list[PeriodicTask] = []
25
26
  self._crons: list[tuple[CronTask, croniter]] = []
@@ -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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinybot-eth
3
- Version: 0.6.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
@@ -24,9 +24,9 @@ pip install tinybot-eth
24
24
 
25
25
  | Variable | Required | Description |
26
26
  |---|---|---|
27
- | `BOT_ACCESS_TOKEN` | Yes | Telegram bot token |
28
- | `GROUP_CHAT_ID` | Yes | Telegram group for notifications |
29
- | `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 |
30
30
  | `PRIVATE_KEY` | No | Private key for onchain execution |
31
31
 
32
32
  ## Quick Start
@@ -85,7 +85,7 @@ asyncio.run(main())
85
85
 
86
86
  ## API
87
87
 
88
- ### `TinyBot(rpc_url, name="tinybot", private_key="")`
88
+ ### `TinyBot(rpc_url, private_rpc_url="", name="tinybot", private_key="")`
89
89
 
90
90
  Creates a bot instance.
91
91
 
@@ -93,6 +93,7 @@ Creates a bot instance.
93
93
  - `bot.state` — `State` instance (see below)
94
94
  - `bot.executor` — `Executor` instance if `private_key` is provided, else `None`
95
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`
96
97
 
97
98
  On `run()`, sends a startup message to `DEV_GROUP_CHAT_ID` and prints a polling heartbeat every tick.
98
99
 
@@ -194,16 +195,20 @@ tx_hash = bot.executor.execute(
194
195
  max_priority_fee_gwei=0, # default: 0
195
196
  simulate=True, # default: True — dry-run via eth_call before sending
196
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
197
200
  )
198
201
  ```
199
202
 
200
203
  - `executor.address` — signer address
201
204
  - `executor.balance` — signer ETH balance in wei
202
- - `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
203
206
  - `gas_limit=0` (default) — auto-estimates gas with 1.5x buffer; pass a value to override
204
207
  - `max_fee_gwei` / `max_priority_fee_gwei` — accept floats (e.g. `0.1`)
205
208
  - `simulate=True` (default) — runs `call.call()` first; reverts raise before the tx is sent
206
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
207
212
 
208
213
  ---
209
214
 
@@ -220,7 +225,7 @@ In-memory state, available via `bot.state`.
220
225
 
221
226
  ---
222
227
 
223
- ### `multicall(w3, calls) -> list`
228
+ ### `multicall(w3, calls, allow_failure=False, batch_size=200) -> list`
224
229
 
225
230
  Batch contract reads via [Multicall3](https://github.com/mds1/multicall).
226
231
 
@@ -229,13 +234,27 @@ symbol, decimals = multicall(bot.w3, [
229
234
  token.functions.symbol(),
230
235
  token.functions.decimals(),
231
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)
232
240
  ```
233
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
+
234
247
  ---
235
248
 
236
249
  ### `notify_group_chat(text, parse_mode="HTML", chat_id=GROUP_CHAT_ID)`
237
250
 
238
- 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.
239
258
 
240
259
  ---
241
260
 
File without changes