mev-bot-watcher 1.7.2__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,3 @@
1
+ from .main import cli
2
+
3
+ cli()
@@ -0,0 +1,119 @@
1
+ import logging
2
+ from typing import Optional, Dict, List, Iterable
3
+
4
+ import aiohttp
5
+
6
+
7
+ COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
8
+
9
+ # Оверрайды для популярных токенов. Нужны для разрешения неоднозначности
10
+ # (у одного тикера может быть много coin id в CoinGecko). Покрывают как
11
+ # нативные токены, так и распространённые стейблкоины (base_tokens).
12
+ _KNOWN_IDS = {
13
+ # native
14
+ "eth": "ethereum",
15
+ "btc": "bitcoin",
16
+ "bnb": "binancecoin",
17
+ "matic": "matic-network",
18
+ "pol": "polygon-ecosystem-token",
19
+ "sol": "solana",
20
+ "avax": "avalanche-2",
21
+ "ftm": "fantom",
22
+ "trx": "tron",
23
+ "near": "near",
24
+ "ada": "cardano",
25
+ "dot": "polkadot",
26
+ "mon": "monad",
27
+ # stablecoins / common base tokens
28
+ "usdc": "usd-coin",
29
+ "usdt": "tether",
30
+ "usde": "ethena-usde",
31
+ "dai": "dai",
32
+ "usds": "usds",
33
+ "frax": "frax",
34
+ "tusd": "true-usd",
35
+ "busd": "binance-usd",
36
+ "usdc.e": "usd-coin",
37
+ }
38
+
39
+
40
+ class CoinGeckoClient:
41
+ """Клиент CoinGecko: резолвит coin id по тикеру и получает цены в USD."""
42
+
43
+ def __init__(self):
44
+ self._session: Optional[aiohttp.ClientSession] = None
45
+ self._symbol_to_ids: Optional[Dict[str, List[str]]] = None
46
+
47
+ async def __aenter__(self):
48
+ self._session = aiohttp.ClientSession()
49
+ return self
50
+
51
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
52
+ if self._session:
53
+ await self._session.close()
54
+ self._session = None
55
+
56
+ async def _ensure_coins_list(self):
57
+ """Одноразовая загрузка списка монет CoinGecko с кешем на процесс."""
58
+ if self._symbol_to_ids is not None:
59
+ return
60
+ url = f"{COINGECKO_BASE_URL}/coins/list"
61
+ async with self._session.get(url) as resp:
62
+ coins = await resp.json()
63
+ mapping: Dict[str, List[str]] = {}
64
+ for coin in coins:
65
+ sym = (coin.get("symbol") or "").lower()
66
+ if not sym:
67
+ continue
68
+ mapping.setdefault(sym, []).append(coin["id"])
69
+ self._symbol_to_ids = mapping
70
+
71
+ async def resolve_id_by_symbol(self, symbol: str) -> Optional[str]:
72
+ """Подбор coingecko id по тикеру.
73
+
74
+ Для wrapped-обёрток (символ начинается с W) первым делом пробуем
75
+ нативный эквивалент: WETH->ETH, WMON->MON и т.д. — это корректно,
76
+ т.к. цены wrapped-токена и нативного равны по смыслу 1:1. Если
77
+ нативный не находится уверенно, падаем обратно к точному поиску
78
+ исходного символа.
79
+ """
80
+ await self._ensure_coins_list()
81
+ sym = symbol.lower()
82
+
83
+ if sym.startswith("w") and len(sym) > 1:
84
+ unwrapped = self._confident_lookup(sym[1:])
85
+ if unwrapped:
86
+ return unwrapped
87
+
88
+ return self._best_effort_lookup(sym)
89
+
90
+ def _confident_lookup(self, sym: str) -> Optional[str]:
91
+ """Возвращает id только если уверены: либо оверрайд, либо id == sym."""
92
+ if sym in _KNOWN_IDS:
93
+ return _KNOWN_IDS[sym]
94
+ for cid in self._symbol_to_ids.get(sym) or []:
95
+ if cid == sym:
96
+ return cid
97
+ return None
98
+
99
+ def _best_effort_lookup(self, sym: str) -> Optional[str]:
100
+ """Точное совпадение по тикеру, даже если однозначности нет."""
101
+ confident = self._confident_lookup(sym)
102
+ if confident:
103
+ return confident
104
+ candidates = self._symbol_to_ids.get(sym) or []
105
+ return candidates[0] if candidates else None
106
+
107
+ async def get_prices_usd(self, ids: Iterable[str]) -> Dict[str, float]:
108
+ """Получение цен сразу пачкой (CoinGecko поддерживает ids через запятую)."""
109
+ unique = sorted({cid for cid in ids if cid})
110
+ if not unique:
111
+ return {}
112
+ url = f"{COINGECKO_BASE_URL}/simple/price?ids={','.join(unique)}&vs_currencies=usd"
113
+ async with self._session.get(url) as resp:
114
+ data = await resp.json()
115
+ return {cid: payload["usd"] for cid, payload in data.items() if "usd" in payload}
116
+
117
+ async def get_price_usd(self, coingecko_id: str) -> Optional[float]:
118
+ prices = await self.get_prices_usd([coingecko_id])
119
+ return prices.get(coingecko_id)
@@ -0,0 +1,116 @@
1
+ from typing import Optional, List, Any, Dict
2
+
3
+ import aiohttp
4
+
5
+
6
+ def _decode_abi_string(hex_result: str) -> str:
7
+ """Декодирование строки из результата eth_call.
8
+
9
+ Поддерживает стандартные ABI dynamic string (offset+length+data),
10
+ а также контракты со старым ABI, возвращающие bytes32 (как MKR).
11
+ """
12
+ if hex_result.startswith('0x'):
13
+ hex_result = hex_result[2:]
14
+ if not hex_result:
15
+ return ''
16
+ # bytes32 fallback: длина данных ≤ 64 hex (32 байта)
17
+ if len(hex_result) < 128:
18
+ return bytes.fromhex(hex_result).rstrip(b'\x00').decode('utf-8', errors='replace')
19
+ length = int(hex_result[64:128], 16)
20
+ data = hex_result[128:128 + length * 2]
21
+ return bytes.fromhex(data).decode('utf-8', errors='replace')
22
+
23
+
24
+ class EthClient:
25
+ """Класс для работы с Ethereum JSON-RPC API"""
26
+
27
+ def __init__(self, rpc_url: str):
28
+ self.rpc_url = rpc_url
29
+ self.session = None
30
+
31
+ async def __aenter__(self):
32
+ self.session = aiohttp.ClientSession()
33
+ return self
34
+
35
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
36
+ if self.session:
37
+ await self.session.close()
38
+
39
+ async def eth_call(self, method: str, params: Optional[List[Any]] = None) -> Any:
40
+ """Асинхронная функция для отправки запросов JSON-RPC"""
41
+ if params is None:
42
+ params = []
43
+
44
+ async with self.session.post(
45
+ self.rpc_url, json={'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params}
46
+ ) as response:
47
+ result = await response.json()
48
+ return result['result']
49
+
50
+ async def get_latest_block(self) -> int:
51
+ """Функция для получения последнего блока"""
52
+ block_number = await self.eth_call('eth_blockNumber')
53
+ return int(block_number, 16)
54
+
55
+ async def get_balance(self, address: str) -> int:
56
+ """Нативный баланс адреса в wei через eth_getBalance"""
57
+ result = await self.eth_call('eth_getBalance', [address, 'latest'])
58
+ return int(result, 16)
59
+
60
+ async def get_erc20_balance(self, token_address: str, holder_address: str) -> int:
61
+ """Баланс ERC20 токена на адресе через вызов balanceOf(address)"""
62
+ # function selector: keccak256("balanceOf(address)")[:4] = 0x70a08231
63
+ padded = holder_address.lower().removeprefix('0x').zfill(64)
64
+ data = '0x70a08231' + padded
65
+ result = await self.eth_call('eth_call', [{'to': token_address, 'data': data}, 'latest'])
66
+ if not result or result == '0x':
67
+ return 0
68
+ return int(result, 16)
69
+
70
+ async def get_block_with_transactions(self, block_number: int | str) -> Dict:
71
+ """Функция для получения блока с полными транзакциями"""
72
+ block = await self.eth_call('eth_getBlockByNumber',
73
+ [hex(block_number) if isinstance(block_number, int) else block_number, True])
74
+ return block
75
+
76
+ async def get_transaction_receipt(self, tx_hash: str) -> Dict:
77
+ """Функция для получения receipt о транзакции"""
78
+ receipt = await self.eth_call('eth_getTransactionReceipt', [tx_hash])
79
+ return receipt
80
+
81
+ async def get_transaction_by_hash(self, tx_hash: str) -> Dict:
82
+ """Функция для получения транзакции по хешу"""
83
+ transaction = await self.eth_call('eth_getTransactionByHash', [tx_hash])
84
+ return transaction
85
+
86
+ async def get_logs(self, from_block: int, to_block: int, address: str, topics: list) -> list:
87
+ """Получение логов по фильтру"""
88
+ params = {
89
+ 'fromBlock': hex(from_block),
90
+ 'toBlock': hex(to_block),
91
+ 'address': address,
92
+ 'topics': topics,
93
+ }
94
+ return await self.eth_call('eth_getLogs', [params])
95
+
96
+ async def get_token_symbol(self, address: str) -> str:
97
+ """Получение символа ERC20 токена через RPC вызов symbol()"""
98
+ # function selector: keccak256("symbol()")[:4] = 0x95d89b41
99
+ result = await self.eth_call('eth_call', [{'to': address, 'data': '0x95d89b41'}, 'latest'])
100
+ return _decode_abi_string(result)
101
+
102
+ async def get_decimals(self, address: str) -> int:
103
+ """Количество знаков ERC20 токена через RPC вызов decimals().
104
+
105
+ Фоллбэк на 18, если контракт не отвечает (нестандартный токен).
106
+ """
107
+ # function selector: keccak256("decimals()")[:4] = 0x313ce567
108
+ result = await self.eth_call('eth_call', [{'to': address, 'data': '0x313ce567'}, 'latest'])
109
+ if not result or result == '0x':
110
+ return 18
111
+ return int(result, 16)
112
+
113
+ async def eth_getBlockReceipts(self, block_number: int | str) -> Dict:
114
+ """Функция для получения receipt о блоке"""
115
+ receipts = await self.eth_call('eth_getBlockReceipts', [hex(block_number) if isinstance(block_number, int) else block_number])
116
+ return receipts
@@ -0,0 +1,56 @@
1
+ import logging
2
+ import time
3
+
4
+ UPDATE_INTERVAL = 15
5
+
6
+
7
+ def print_progress(tasks, total_tasks=0, update_interval=UPDATE_INTERVAL):
8
+ completed_tasks = 0
9
+ start_time = time.time()
10
+ last_update_time = start_time
11
+ total_tasks = total_tasks if total_tasks else len(tasks)
12
+
13
+ for future in tasks:
14
+ yield future
15
+ completed_tasks += 1
16
+ current_time = time.time()
17
+ if current_time - last_update_time >= update_interval:
18
+ calculate_progress(completed_tasks, current_time, start_time, total_tasks)
19
+ last_update_time = current_time
20
+
21
+
22
+ def calculate_progress(completed_tasks, current_time, start_time, total_tasks):
23
+ progress_percentage = (completed_tasks / total_tasks) * 100
24
+ elapsed_time = current_time - start_time
25
+ avg_time_per_task = elapsed_time / completed_tasks if completed_tasks > 0 else 0
26
+ remaining_tasks = total_tasks - completed_tasks
27
+ estimated_remaining_time = avg_time_per_task * remaining_tasks
28
+ logging.info(
29
+ f"Progress: {completed_tasks}/{total_tasks} tasks completed "
30
+ f"({progress_percentage:.2f}%). "
31
+ f"Estimated remaining time: {estimated_remaining_time:.2f} seconds."
32
+ )
33
+
34
+
35
+ def setup_logging():
36
+ class ColorFormatter(logging.Formatter):
37
+ COLORS = {
38
+ 'DEBUG': '\033[96m', # Cyan
39
+ 'INFO': '\033[92m', # Green
40
+ 'WARNING': '\033[93m', # Yellow
41
+ 'ERROR': '\033[91m', # Red
42
+ 'CRITICAL': '\033[95m', # Magenta
43
+ }
44
+ RESET = '\033[0m'
45
+
46
+ def format(self, record):
47
+ log_color = self.COLORS.get(record.levelname, self.RESET)
48
+ message = super().format(record) # Используем стандартный форматтер
49
+ return f"{log_color}{message}{self.RESET}"
50
+
51
+ logger = logging.getLogger("")
52
+ handler = logging.StreamHandler()
53
+ handler.setFormatter(ColorFormatter())
54
+
55
+ logger.setLevel(logging.INFO)
56
+ logger.addHandler(handler)
@@ -0,0 +1,222 @@
1
+ import logging
2
+ import asyncio
3
+ import yaml
4
+ import click
5
+ from typing import Dict, List, Optional
6
+
7
+ from .tx_watcher import TxWatcher
8
+ from .eth_client import EthClient
9
+ from .coingecko_client import CoinGeckoClient
10
+ from .log_progress import setup_logging
11
+ from .telegram_notifier import BotInfo
12
+ from .tx_analyzer import TxAnalyzer, TokenInfo
13
+ from .ws_connector import WsConnectorRaw
14
+
15
+
16
+ def load_config(config_path: str) -> Dict:
17
+ """Загрузка конфигурации из YAML файла"""
18
+ with open(config_path, 'r') as file:
19
+ return yaml.safe_load(file)
20
+
21
+
22
+ def get_bot_config_by_name(config: Dict, bot_name: str) -> Optional[Dict]:
23
+ """Получение конфигурации бота по имени"""
24
+ if 'bots' not in config:
25
+ return None
26
+
27
+ for name, bot_config in config['bots'].items():
28
+ if name == bot_name:
29
+ return bot_config
30
+
31
+ return None
32
+
33
+
34
+ def get_token_addresses(bot_config: Dict) -> tuple:
35
+ """Возвращает (wrapped_token, [base_tokens]) из конфигурации бота."""
36
+ wrapped = bot_config['wrapped_token'].lower()
37
+ base = [a.lower() for a in (bot_config.get('base_tokens') or [])]
38
+ return wrapped, base
39
+
40
+
41
+ async def build_tokens(eth_client: EthClient, cg_client: CoinGeckoClient,
42
+ wrapped: str, base: List[str]) -> List[TokenInfo]:
43
+ """Собирает список TokenInfo: wrapped (is_wrapped=True) + base_tokens."""
44
+ tokens = [await TokenInfo.from_rpc(eth_client, cg_client, wrapped, is_wrapped=True)]
45
+ for addr in base:
46
+ tokens.append(await TokenInfo.from_rpc(eth_client, cg_client, addr, is_wrapped=False))
47
+ return tokens
48
+
49
+
50
+ def parse_ws_rpc(ws_rpc) -> tuple:
51
+ """Разбирает ws_rpc_url как строку или dict с login/password.
52
+
53
+ Returns (url, login, password). login/password = None если не заданы.
54
+ """
55
+ if ws_rpc is None:
56
+ return None, None, None
57
+ if isinstance(ws_rpc, str):
58
+ return ws_rpc, None, None
59
+ if isinstance(ws_rpc, dict):
60
+ return ws_rpc.get('url'), ws_rpc.get('login'), ws_rpc.get('password')
61
+ raise ValueError(f"ws_rpc_url must be string or dict, got {type(ws_rpc).__name__}")
62
+
63
+
64
+ @click.group()
65
+ def cli():
66
+ """Утилита для анализа и мониторинга Ethereum транзакций"""
67
+ setup_logging()
68
+
69
+
70
+ @cli.command()
71
+ @click.option('--config', '-c', required=True, type=click.Path(exists=True), default='config.yaml', help='Путь к файлу конфигурации')
72
+ @click.option('--bot-name', '-b', required=True, type=str, help='Имя бота в конфигурации')
73
+ @click.option('--start-block', '-s', type=int, help='Номер блока, с которого начать анализ (до последнего блока)')
74
+ @click.option('--block', '-n', 'block', type=int, help='Номер одиночного блока для отладочного прогона')
75
+ def analyze(config: str, bot_name: str, start_block: Optional[int], block: Optional[int]):
76
+ """Режим анализа: диапазон от start-block или одиночный блок через функции подсчёта"""
77
+
78
+ if (start_block is None) == (block is None):
79
+ raise click.UsageError("Specify exactly one of --start-block / --block")
80
+
81
+ # Загружаем конфигурацию
82
+ full_config = load_config(config)
83
+
84
+ # Получаем конфигурацию указанного бота
85
+ bot_config = get_bot_config_by_name(full_config, bot_name)
86
+
87
+ if not bot_config:
88
+ available_bots = ", ".join(full_config['bots'].keys())
89
+ logging.error(f"Bot '{bot_name}' not found in config. Available bots: {available_bots}")
90
+ return
91
+
92
+ # Извлекаем настройки для указанного бота
93
+ wrapped, base = get_token_addresses(bot_config)
94
+ watched_address = bot_config['watched_address'].lower()
95
+ http_rpc_url = bot_config['http_rpc_url']
96
+
97
+ logging.info(f"Starting analysis for bot '{bot_name}':")
98
+ logging.info(f"Wrapped token: {wrapped}")
99
+ logging.info(f"Base tokens: {base}")
100
+ logging.info(f"Watched address: {watched_address}")
101
+ if start_block is not None:
102
+ logging.info(f"Starting from block: {start_block}")
103
+ else:
104
+ logging.info(f"Single block: {block}")
105
+
106
+ # Запускаем анализ
107
+ async def run_analysis():
108
+ async with EthClient(http_rpc_url) as eth_client, CoinGeckoClient() as cg_client:
109
+ tokens = await build_tokens(eth_client, cg_client, wrapped, base)
110
+ for t in tokens:
111
+ logging.info(f"Token {t.symbol} ({t.address}) decimals={t.decimals} "
112
+ f"coingecko_id={t.coingecko_id}")
113
+ analyzer = TxAnalyzer(eth_client, tokens, watched_address, cg_client=cg_client)
114
+ if start_block is not None:
115
+ await analyzer.analyze_from_block(start_block)
116
+ else:
117
+ await analyzer.analyze_single_block(block)
118
+
119
+ asyncio.run(run_analysis())
120
+
121
+
122
+ @cli.command()
123
+ @click.option('--config', '-c', required=True, type=click.Path(exists=True), default='config.yaml', help='Путь к файлу конфигурации')
124
+ @click.option('--bot-name', '-b', type=str, help='Имя конкретного бота (если не указано, мониторятся все боты)')
125
+ def monitor(config: str, bot_name: Optional[str] = None):
126
+ """Режим мониторинга: постоянно отслеживать новые транзакции для всех или указанного бота"""
127
+
128
+ full_config = load_config(config)
129
+
130
+ if bot_name:
131
+ # Мониторинг только одного бота
132
+ bot_config = get_bot_config_by_name(full_config, bot_name)
133
+ if not bot_config:
134
+ available_bots = ", ".join(full_config['bots'].keys())
135
+ logging.error(f"Bot '{bot_name}' not found in config. Available bots: {available_bots}")
136
+ return
137
+ bots_to_monitor = {bot_name: bot_config}
138
+ else:
139
+ # Мониторинг всех ботов
140
+ bots_to_monitor = full_config['bots']
141
+
142
+ logging.info(f"Starting monitoring for {len(bots_to_monitor)} bots")
143
+
144
+ telegram_config = full_config.get('telegram')
145
+
146
+ async def run_monitoring():
147
+ cg_client = CoinGeckoClient()
148
+ await cg_client.__aenter__()
149
+
150
+ # Настройка Telegram уведомлений
151
+ notifier = None
152
+ if telegram_config and telegram_config.get('bot_token'):
153
+ from telegram_notifier import TelegramNotifier
154
+ schedule = telegram_config.get('notify_schedule', '0 * * * *')
155
+ notifier = TelegramNotifier(
156
+ bot_token=telegram_config['bot_token'],
157
+ chat_id=str(telegram_config['chat_id']),
158
+ notify_schedule=schedule,
159
+ cg_client=cg_client,
160
+ )
161
+ logging.info(f"Telegram notifications enabled (schedule: {schedule})")
162
+
163
+ tasks = []
164
+ eth_clients = []
165
+ ws_connectors = []
166
+
167
+ for name, bot_cfg in bots_to_monitor.items():
168
+ wrapped, base = get_token_addresses(bot_cfg)
169
+ watched_address = bot_cfg['watched_address'].lower()
170
+ scanner_url = bot_cfg.get('scanner_url')
171
+ loss_alert_usd = bot_cfg.get('loss_alert_usd')
172
+ ws_url, ws_login, ws_password = parse_ws_rpc(bot_cfg.get('ws_rpc_url'))
173
+ http_rpc_url = bot_cfg.get('http_rpc_url')
174
+
175
+ logging.info(f"Setting up monitoring for bot '{name}':")
176
+ logging.info(f"Wrapped token: {wrapped}")
177
+ logging.info(f"Base tokens: {base}")
178
+ logging.info(f"Watched address: {watched_address}")
179
+
180
+ ws = WsConnectorRaw(ws_url, login=ws_login, password=ws_password, name=name)
181
+ ws_connectors.append(ws)
182
+ eth_client = EthClient(http_rpc_url)
183
+ await eth_client.__aenter__()
184
+ eth_clients.append(eth_client)
185
+
186
+ tokens = await build_tokens(eth_client, cg_client, wrapped, base)
187
+ bot_info = await BotInfo.from_rpc(eth_client, name, watched_address, tokens,
188
+ scanner_url=scanner_url,
189
+ loss_alert_usd=loss_alert_usd)
190
+ if loss_alert_usd is not None:
191
+ logging.info(f"Instant loss alerts enabled (threshold: ${loss_alert_usd})")
192
+
193
+ if notifier:
194
+ notifier.register_bot(bot_info)
195
+
196
+ watcher = TxWatcher(eth_client, tokens, watched_address,
197
+ bot_name=name, notifier=notifier)
198
+ await watcher.subscribe(ws)
199
+ tasks.append(ws.run())
200
+
201
+ if notifier:
202
+ async def send_startup_after_connect():
203
+ await asyncio.gather(*(ws.ready.wait() for ws in ws_connectors))
204
+ await notifier.send_startup_message()
205
+
206
+ tasks.append(send_startup_after_connect())
207
+ tasks.append(notifier.run_periodic_flush())
208
+
209
+ try:
210
+ await asyncio.gather(*tasks)
211
+ finally:
212
+ if notifier:
213
+ await notifier.force_flush()
214
+ for client in eth_clients:
215
+ await client.__aexit__(None, None, None)
216
+ await cg_client.__aexit__(None, None, None)
217
+
218
+ asyncio.run(run_monitoring())
219
+
220
+
221
+ if __name__ == "__main__":
222
+ cli()