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.
- mev_bot_watcher/__init__.py +0 -0
- mev_bot_watcher/__main__.py +3 -0
- mev_bot_watcher/coingecko_client.py +119 -0
- mev_bot_watcher/eth_client.py +116 -0
- mev_bot_watcher/log_progress.py +56 -0
- mev_bot_watcher/main.py +222 -0
- mev_bot_watcher/telegram_notifier.py +404 -0
- mev_bot_watcher/tx_analyzer.py +341 -0
- mev_bot_watcher/tx_watcher.py +50 -0
- mev_bot_watcher/ws_connector.py +136 -0
- mev_bot_watcher-1.7.2.dist-info/METADATA +226 -0
- mev_bot_watcher-1.7.2.dist-info/RECORD +16 -0
- mev_bot_watcher-1.7.2.dist-info/WHEEL +5 -0
- mev_bot_watcher-1.7.2.dist-info/entry_points.txt +2 -0
- mev_bot_watcher-1.7.2.dist-info/licenses/LICENSE +21 -0
- mev_bot_watcher-1.7.2.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -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)
|
mev_bot_watcher/main.py
ADDED
|
@@ -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()
|