Riy777 commited on
Commit
c974ce2
·
verified ·
1 Parent(s): 6c90167

Update whale_monitor/rpc_manager.py

Browse files
Files changed (1) hide show
  1. whale_monitor/rpc_manager.py +357 -325
whale_monitor/rpc_manager.py CHANGED
@@ -1,419 +1,451 @@
1
  # whale_monitor/rpc_manager.py
2
- # (V2.1 - إضافة دعم Solscan API)
3
- # هذا هو "الوكيل الذكي" لإدارة اتصالات RPC والمستكشفات (APIs)
4
- # يدير منظمات الطلبات (Rate Limiters) والإحصائيات
5
 
6
  import asyncio
7
  import httpx
8
  import time
9
- import ssl
10
- import json
11
  import os
12
  import csv
13
- from collections import deque, defaultdict
14
  import random
15
- from typing import Dict, Any, Optional, List
 
 
16
 
17
- # استيراد الإعدادات الثابتة
18
- from .config import DEFAULT_NETWORK_CONFIGS, COINGECKO_BASE_URL
 
 
19
 
20
- # إعدادات الوكيل الذكي
21
- RPC_HEALTH_CHECK_WINDOW = 10 # تتبع آخر 10 طلبات
22
- RPC_ERROR_THRESHOLD = 3 # عدد الأخطاء المتتالية لإيقاف مؤقت
23
- RPC_CIRCUIT_BREAKER_DURATION = 300 # 5 دقائق إيقاف مؤقت
24
 
25
- # (إضافة ثابت لفرض تأخير بين طلبات CoinGecko)
26
- COINGECKO_REQUEST_DELAY = 2.0 # 2.0 ثانية (يساوي 30 طلب/دقيقة كحد أقصى)
 
 
 
 
 
 
27
 
28
- # (تحديد المسار الحالي لملف rpc_manager.py)
29
  _CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
30
- # (تحديد مسار ملف CSV داخل نفس المجلد)
31
  CUSTOM_RPC_CSV_FILE = os.path.join(_CURRENT_DIR, 'rpc_endpoints (1).csv')
32
 
33
  class AdaptiveRpcManager:
34
  """
35
- (محدث V2.1)
36
- مدير RPC و API ذكي:
37
- 1. يدير صحة نقاط RPC العامة (Public).
38
- 2. يدير منظمات الطلبات (Rate Limiters) للمفاتيح الخاصة (Infura, Moralis, Scanners, Solscan).
39
- 3. يتتبع إحصائيات الجلسة (Session Stats) لجميع الطلبات.
 
40
  """
 
41
  def __init__(self, http_client: httpx.AsyncClient):
 
42
  self.http_client = http_client
43
 
44
- # 1. تحميل المفاتيح الخاصة من متغيرات البيئة
45
  self.api_keys = {
46
  "infura": os.getenv("INFURA_KEY"),
47
  "moralis": os.getenv("MORALIS_KEY"),
48
  "etherscan": os.getenv("ETHERSCAN_KEY"),
49
  "bscscan": os.getenv("BSCSCAN_KEY"),
50
  "polygonscan": os.getenv("POLYGONSCAN_KEY"),
51
- # 🔴 --- START OF CHANGE (V2.1) --- 🔴
52
- "solscan": os.getenv("SOLSCAN_KEY"), # (إضافة مفتاح Solscan)
53
- # 🔴 --- END OF CHANGE --- 🔴
54
  }
55
- print("✅ [RPCManager V2.1] تم تحميل المفاتيح الخاصة من البيئة.")
56
-
57
- # 2. تهيئة منظمات الطلبات (Semaphores)
58
-
59
- # لـ Infura (500 credits/sec) - سنستخدم 450 كحد أمان
60
- self.infura_semaphore = asyncio.Semaphore(450)
61
 
62
- # لـ Moralis (40k/day ~ 0.46/sec) - سنستخدم 1 لضمان طلب واحد في كل مرة
63
- self.moralis_semaphore = asyncio.Semaphore(1)
64
-
65
- # لـ Etherscan, BscScan, PolygonScan (الحد المشترك 5/sec)
66
- self.scanner_semaphore = asyncio.Semaphore(5)
67
-
68
- # 🔴 --- START OF CHANGE (V2.1) --- 🔴
69
- # لـ Solscan (1000 reqs/60 sec ~ 16.6/sec) - سنستخدم 15 كحد أمان
70
- self.solscan_semaphore = asyncio.Semaphore(15)
71
- # 🔴 --- END OF CHANGE --- 🔴
72
-
73
- # لـ CoinGecko (عام، سنكون حذرين)
74
- self.coingecko_semaphore = asyncio.Semaphore(1)
75
-
76
- # لـ مجمع RPC العام (Public Pool) (لحمايتهم من الضغط)
77
- self.public_rpc_semaphore = asyncio.Semaphore(10)
78
 
79
- self.last_coingecko_call = 0.0
80
- self.last_moralis_call = 0.0
81
-
82
- # 3. تهيئة إحصائيات الجلسة
83
  self.session_stats = defaultdict(int)
84
 
85
- # 4. تهيئة إعدادات الشبكة ونقاط RPC
86
- self.network_configs = self._initialize_network_configs(DEFAULT_NETWORK_CONFIGS)
87
-
88
- # 5. نظام تتبع الصحة (فقط لنقاط RPC العامة)
89
  self.endpoint_health = defaultdict(lambda: defaultdict(lambda: {
90
  "latency": deque(maxlen=RPC_HEALTH_CHECK_WINDOW),
91
- "consecutive_errors": 0, "total_errors": 0, "last_error_time": None,
92
- "circuit_open": False,
 
93
  }))
 
 
 
94
 
95
- print("✅ [RPCManager V2.1] مدير RPC/API الذكي (V2.1) مهيأ.")
96
-
 
 
 
 
 
 
 
97
  def _load_rpc_from_csv(self, csv_file_path: str) -> Dict[str, List[str]]:
98
- """
99
- (جديد V2)
100
- قراءة ملف rpc_endpoints (1).csv ودمج النقاط.
101
- يتوقع الملف أن يحتوي على عمودين: 'network' و 'url'
102
- """
103
  custom_rpcs = defaultdict(list)
104
  if not os.path.exists(csv_file_path):
105
- print(f"⚠️ [RPCManager V2] ملف CSV المخصص '{csv_file_path}' غير موجود. سيتم تخطيه.")
106
  return custom_rpcs
107
 
108
  try:
109
- with open(csv_file_path, mode='r', encoding='utf-8') as f:
110
  reader = csv.DictReader(f)
 
111
  for row in reader:
112
- network = row.get('network')
113
- url = row.get('url')
114
- if network and url and url.startswith('http'):
115
- custom_rpcs[network].append(url)
116
- print(f"✅ [RPCManager V2] تم تحميل {sum(len(v) for v in custom_rpcs.values())} نقطة RPC مخصصة من {csv_file_path}")
 
 
 
 
117
  return custom_rpcs
118
  except Exception as e:
119
- print(f"❌ [RPCManager V2] فشل في قراءة ملف CSV '{csv_file_path}': {e}")
120
  return defaultdict(list)
121
 
122
- def _initialize_network_configs(self, configs):
123
- """
124
- (محدث V2.1)
125
- يقوم بدمج CSV، وحقن مفاتيح API، وإعداد الشبكات.
126
- """
127
- initialized_configs = {}
128
-
129
- # 1. تحميل نقاط CSV المخصصة
130
  custom_rpcs = self._load_rpc_from_csv(CUSTOM_RPC_CSV_FILE)
131
-
 
 
 
132
  for network, config in configs.items():
 
133
  new_config = config.copy()
 
134
 
135
- # 2. حقن مفاتيح Infura
136
- new_config['rpc_endpoints'] = self._inject_api_keys(
137
- config['rpc_endpoints'],
138
- self.api_keys.get('infura')
139
- )
 
 
 
 
140
 
141
- # 3. دمج نقاط CSV
142
  if network in custom_rpcs:
143
- new_config['rpc_endpoints'].extend(custom_rpcs[network])
144
- print(f" ... دمج {len(custom_rpcs[network])} نقاط مخصصة لشبكة {network}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
- # 4. خلط القائمة النهائية (لضمان التوزيع)
147
- random.shuffle(new_config['rpc_endpoints'])
148
 
149
- # 5. حقن مفاتيح المستكشف (Explorer)
150
  if config.get('explorer'):
151
- explorer_key_name = config['explorer'].get('api_key_name')
152
- if explorer_key_name and explorer_key_name in self.api_keys:
153
- new_config['explorer']['api_key'] = self.api_keys[explorer_key_name]
154
- else:
155
- new_config['explorer']['api_key'] = None # مفتاح غير متوفر
156
 
157
- initialized_configs[network] = new_config
158
- return initialized_configs
159
 
160
- def _inject_api_keys(self, endpoints: list, infura_key: str):
 
 
 
 
161
  """
162
- يستبدل <INFURA_KEY> بالمفتاح الفعلي.
 
163
  """
164
- if not infura_key:
165
- # إزالة النقاط التي تعتمد على مفتاح غير متوفر
166
- return [ep for ep in endpoints if "<INFURA_KEY>" not in ep]
167
-
168
- return [ep.replace("<INFURA_KEY>", infura_key) for ep in endpoints]
169
-
170
- # --- (دوال مساعدة للحصول على الإعدادات) ---
171
-
172
- def get_network_configs(self):
173
- return self.network_configs
174
-
175
- def get_explorer_config(self, network: str):
176
- config = self.network_configs.get(network, {})
177
- return config.get('explorer')
178
-
179
- def get_api_key(self, key_name: str) -> Optional[str]:
180
- """(جديد V2) جلب مفتاح API بأمان"""
181
- return self.api_keys.get(key_name)
182
-
183
- # --- (دوال إدارة الإحصائيات V2) ---
184
-
185
- def reset_session_stats(self):
186
- """(جديد V2) تصفير عدادات الإحصائيات للجلسة الجديدة"""
187
- self.session_stats = defaultdict(int)
188
- print("📊 [RPCManager V2.1] تم تصفير عدادات إحصائيات الجلسة.")
189
 
190
- def get_session_stats(self) -> Dict[str, int]:
191
- """(جديد V2) إرجاع نسخة من الإحصائيات الحالية"""
192
- return self.session_stats.copy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
- # --- (دوال الصحة لـ Public RPCs - لا تغيير) ---
 
 
195
 
196
- def _get_healthy_public_endpoints(self, network: str):
197
  """
198
- (معدل V2)
199
- يرتب نقاط RPC العامة (فقط) بناءً على الصحة.
 
200
  """
201
- if network not in self.network_configs: return []
 
 
 
 
 
 
 
 
202
 
203
- endpoints = self.network_configs[network]['rpc_endpoints']
204
- healthy_endpoints = []
205
- current_time = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
 
207
  for ep in endpoints:
208
- # (تخطي Infura، هذا المجمع للعام فقط)
209
- if "infura.io" in ep:
210
- continue
211
-
212
- health = self.endpoint_health[network][ep]
213
- if health['circuit_open']:
214
- if current_time - health['last_error_time'] > RPC_CIRCUIT_BREAKER_DURATION:
215
- health['circuit_open'] = False; health['consecutive_errors'] = 0
216
  else:
217
- continue # ��لنقطة لا تزال معطلة مؤقتاً
218
 
219
- avg_latency = sum(health['latency']) / len(health['latency']) if health['latency'] else float('inf')
220
- healthy_endpoints.append((ep, avg_latency, health['consecutive_errors']))
221
-
222
- healthy_endpoints.sort(key=lambda x: (x[2], x[1]))
223
-
224
- # (فصل السليم تماماً عن غير السليم)
225
- healthy_list = [ep[0] for ep in healthy_endpoints if ep[2] == 0]
226
- random.shuffle(healthy_list) # خلط السليم للتوزيع
227
- unhealthy_list = [ep[0] for ep in healthy_endpoints if ep[2] > 0]
228
-
229
- return healthy_list + unhealthy_list
230
 
231
  def _update_health(self, network: str, endpoint: str, success: bool, latency: float):
232
- """(لا تغيير) تحديث إحصائيات صحة نقطة RPC العامة."""
233
- health = self.endpoint_health[network][endpoint]
234
  if success:
235
- health['latency'].append(latency)
236
- health['consecutive_errors'] = 0
237
- health['circuit_open'] = False
238
  else:
239
- health['consecutive_errors'] += 1; health['total_errors'] += 1
240
- health['last_error_time'] = time.time()
241
- if health['consecutive_errors'] >= RPC_ERROR_THRESHOLD:
242
- health['circuit_open'] = True
243
- print(f"🚨 [RPCManager V2.1] قاطع الدائرة مفعل (عام)! إيقاف مؤقت لـ: {endpoint.split('//')[-1]}")
244
 
245
- # --- (دوال الاتصال الأساسية V2.1 - محدثة بالكامل) ---
 
 
246
 
247
- async def post_rpc(self, network: str, payload: dict, timeout: float = 20.0):
248
  """
249
- (محدث V2)
250
- إرسال طلب POST (JSON-RPC)
251
- سيحاول مع Infura أولاً (إذا كان متاحاً)، ثم يلجأ إلى المجمع العام.
252
  """
 
253
 
254
- # 1. محاولة Infura أولاً (الأولوية)
255
- infura_key = self.api_keys.get('infura')
256
- infura_endpoint = next((ep for ep in self.network_configs.get(network, {}).get('rpc_endpoints', []) if "infura.io" in ep), None)
257
-
258
- if infura_key and infura_endpoint:
259
- start_time = time.time()
260
- try:
261
- async with self.infura_semaphore:
262
- response = await self.http_client.post(infura_endpoint, json=payload, timeout=timeout)
263
- response.raise_for_status()
264
-
265
- self.session_stats['infura_success'] += 1
266
- latency = time.time() - start_time
267
- print(f"✅ [RPC Infura] {network} - {latency:.2f}s")
268
- return response.json()
269
 
270
- except Exception as e:
271
- self.session_stats['infura_fail'] += 1
272
- print(f"⚠️ [RPC Infura] فشل {network}: {type(e).__name__}. اللجوء إلى المجمع العام...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
- # 2. اللجوء إلى المجمع العام (Public Pool)
275
- endpoints = self._get_healthy_public_endpoints(network)
276
- if not endpoints:
277
- print(f"❌ [RPCManager V2.1] لا توجد نقاط RPC عامة متاحة لشبكة {network}")
 
278
  return None
279
 
280
- for endpoint in endpoints[:3]: # محاولة أفضل 3
281
- start_time = time.time()
282
- ep_name = endpoint.split('//')[-1].split('/')[0]
283
- try:
284
- async with self.public_rpc_semaphore:
285
- response = await self.http_client.post(endpoint, json=payload, timeout=timeout)
286
- response.raise_for_status()
 
 
287
 
288
- latency = time.time() - start_time
289
- self._update_health(network, endpoint, success=True, latency=latency)
290
- self.session_stats['public_rpc_success'] += 1
291
- print(f"✅ [RPC Public] {network} ({ep_name}) - {latency:.2f}s")
292
- return response.json()
293
 
 
 
 
 
 
 
294
  except Exception as e:
295
- latency = time.time() - start_time
296
- self._update_health(network, endpoint, success=False, latency=latency)
297
- self.session_stats['public_rpc_fail'] += 1
298
- print(f"⚠️ [RPC Public] فشل {network} ({ep_name}): {type(e).__name__}")
299
- continue
300
-
301
- print(f"❌ [RPCManager V2.1] فشلت جميع محاولات RPC لشبكة {network}")
302
- return None
303
 
304
- async def get_scanner_api(self, base_url: str, params: dict, timeout: float = 15.0):
305
- """
306
- (جديد V2)
307
- إجراء طلب GET لواجهات Scanners (Etherscan, BscScan, etc.)
308
- يستخدم المنظم المشترك (5/ثانية).
309
- """
310
- self.session_stats['scanner_total'] += 1
311
- try:
312
- async with self.scanner_semaphore:
313
- response = await self.http_client.get(base_url, params=params, headers=None, timeout=timeout)
314
- response.raise_for_status()
315
- self.session_stats['scanner_success'] += 1
316
- return response.json()
317
- except Exception as e:
318
- self.session_stats['scanner_fail'] += 1
319
- print(f"❌ [Scanner API] فشل الطلب من {base_url.split('//')[-1]}: {e}")
320
- return None
321
-
322
- # 🔴 --- START OF CHANGE (V2.1) --- 🔴
323
- async def get_solscan_api(self, path: str, params: dict, timeout: float = 15.0):
324
- """
325
- (جديد V2.1)
326
- إجراء طلب GET لـ Solscan Pro API.
327
- يستخدم المنظم الخاص به (15/ثانية) ومفتاح API.
328
- """
329
- solscan_key = self.api_keys.get('solscan')
330
- if not solscan_key:
331
- print("❌ [Solscan API] لا يوجد مفتاح SOLSCAN_KEY.")
332
- self.session_stats['solscan_fail_key'] += 1
333
- return None
334
 
335
- base_url = self.network_configs.get('solana', {}).get('explorer', {}).get('api_url', 'https://pro-api.solscan.io')
336
- full_url = f"{base_url}{path}"
337
- headers = {"accept": "application/json", "token": solscan_key}
338
- self.session_stats['solscan_total'] += 1
339
 
340
- try:
341
- async with self.solscan_semaphore:
342
- response = await self.http_client.get(full_url, params=params, headers=headers, timeout=timeout)
343
- response.raise_for_status()
344
-
345
- self.session_stats['solscan_success'] += 1
346
- return response.json()
347
- except Exception as e:
348
- self.session_stats['solscan_fail'] += 1
349
- print(f"❌ [Solscan API] فشل الطلب من {path}: {e}")
350
- return None
351
- # 🔴 --- END OF CHANGE --- 🔴
352
-
353
- async def get_moralis_api(self, base_url: str, params: dict, timeout: float = 20.0):
354
- """
355
- (جديد V2)
356
- إجراء طلب GET لـ Moralis API.
357
- يستخدم المنظم الخاص به (1/ثانية) ومفتاح API.
358
- """
359
- moralis_key = self.api_keys.get('moralis')
360
- if not moralis_key:
361
- print("❌ [Moralis API] لا يوجد مفتاح MORALIS_KEY.")
362
- return None
363
-
364
- headers = {"accept": "application/json", "X-API-Key": moralis_key}
365
- self.session_stats['moralis_total'] += 1
366
-
367
- try:
368
- async with self.moralis_semaphore:
369
- # (ضمان وجود ثانية واحدة على الأقل بين الطلبات لتوزيع 40k على اليوم)
370
- current_time = time.time()
371
- if current_time - self.last_moralis_call < 1.0:
372
- await asyncio.sleep(1.0 - (current_time - self.last_moralis_call))
373
- self.last_moralis_call = time.time()
374
 
375
- response = await self.http_client.get(base_url, params=params, headers=headers, timeout=timeout)
376
- response.raise_for_status()
 
 
377
 
378
- self.session_stats['moralis_success'] += 1
379
- return response.json()
380
- except Exception as e:
381
- self.session_stats['moralis_fail'] += 1
382
- print(f"❌ [Moralis API] فشل الطلب: {e}")
383
- return None
 
384
 
385
- async def get_coingecko_api(self, params: dict, headers: dict = None, timeout: float = 15.0):
386
- """
387
- (معدل V2)
388
- إجراء طلب GET لـ CoinGecko (يستخدم الآن إحصائيات ومنظم خاص).
389
- """
390
- base_url = COINGECKO_BASE_URL
391
- self.session_stats['coingecko_total'] += 1
392
- try:
393
- async with self.coingecko_semaphore:
394
- # (تطبيق "الخنق" لـ CoinGecko)
395
- current_time = time.time()
396
- time_since_last = current_time - self.last_coingecko_call
397
- if time_since_last < COINGECKO_REQUEST_DELAY:
398
- wait_time = COINGECKO_REQUEST_DELAY - time_since_last
399
- await asyncio.sleep(wait_time)
400
- self.last_coingecko_call = time.time()
401
-
402
- response = await self.http_client.get(base_url, params=params, headers=headers, timeout=timeout)
403
-
404
- if response.status_code == 429: # Too Many Requests
405
- wait_duration = 15.0
406
- print(f"⚠️ [CoinGecko] خطأ 429 (Rate Limit). الانتظار {wait_duration} ثوان...")
407
- await asyncio.sleep(wait_duration)
408
- self.last_coingecko_call = time.time()
409
- response = await self.http_client.get(base_url, params=params, headers=headers, timeout=timeout)
410
 
411
- response.raise_for_status()
412
-
413
- self.session_stats['coingecko_success'] += 1
414
- return response.json()
415
-
416
- except Exception as e:
417
- self.session_stats['coingecko_fail'] += 1
418
- print(f"❌ [CoinGecko] فشل الطلب: {e}")
419
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # whale_monitor/rpc_manager.py
2
+ # (V3.2 - GEM-Architect: Full Enterprise Edition)
3
+ # يتضمن: Web3 Engine + Raw RPC Fallback + CSV Injection + Strict API Handling
 
4
 
5
  import asyncio
6
  import httpx
7
  import time
 
 
8
  import os
9
  import csv
10
+ import json
11
  import random
12
+ import traceback
13
+ from collections import defaultdict, deque
14
+ from typing import Dict, Any, Optional, List, Union
15
 
16
+ # Web3 Imports
17
+ from web3 import AsyncWeb3
18
+ from web3.providers import AsyncHTTPProvider
19
+ from web3.middleware import async_geth_poa_middleware
20
 
21
+ # Local Imports
22
+ from .config import DEFAULT_NETWORK_CONFIGS, COINGECKO_BASE_URL
 
 
23
 
24
+ # --- Constants & Limits ---
25
+ # التأكد من عدم الحظر من قبل API العامة
26
+ RPC_HEALTH_CHECK_WINDOW = 15
27
+ RPC_ERROR_THRESHOLD = 5
28
+ RPC_CIRCUIT_BREAKER_DURATION = 300 # 5 Minutes
29
+ COINGECKO_REQUEST_DELAY = 1.6 # Safety margin for free tier
30
+ MORALIS_RATE_LIMIT = 1.0 # 1 req/sec
31
+ SOLSCAN_RATE_LIMIT = 0.5 # 2 req/sec
32
 
33
+ # Paths
34
  _CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
 
35
  CUSTOM_RPC_CSV_FILE = os.path.join(_CURRENT_DIR, 'rpc_endpoints (1).csv')
36
 
37
  class AdaptiveRpcManager:
38
  """
39
+ مدير الاتصالات المركزي (Central Connection Manager).
40
+ يدير:
41
+ 1. نقاط Web3 للاتصال المباشر بالعقود (EVM).
42
+ 2. طلبات HTTP RPC الخام (Raw JSON-RPC) كاحتياطي.
43
+ 3. واجهات API الخارجية (CoinGecko, Solscan, Moralis, Scanners).
44
+ 4. تحميل ودمج نقاط اتصال مخصصة من CSV.
45
  """
46
+
47
  def __init__(self, http_client: httpx.AsyncClient):
48
+ print("🔄 [RPCManager V3.2] Initializing Enterprise Connection Engine...")
49
  self.http_client = http_client
50
 
51
+ # 1. Load Secure Keys
52
  self.api_keys = {
53
  "infura": os.getenv("INFURA_KEY"),
54
  "moralis": os.getenv("MORALIS_KEY"),
55
  "etherscan": os.getenv("ETHERSCAN_KEY"),
56
  "bscscan": os.getenv("BSCSCAN_KEY"),
57
  "polygonscan": os.getenv("POLYGONSCAN_KEY"),
58
+ "solscan": os.getenv("SOLSCAN_KEY"),
 
 
59
  }
 
 
 
 
 
 
60
 
61
+ # 2. Rate Limiting Semaphores (Concurreny Control)
62
+ self.semaphores = {
63
+ 'coingecko': asyncio.Semaphore(1), # Strict Serial
64
+ 'solscan': asyncio.Semaphore(5), # Parallel allowed
65
+ 'moralis': asyncio.Semaphore(2), # Limited parallel
66
+ 'scanners': asyncio.Semaphore(5), # Shared limit for Etherscan clones
67
+ 'infura': asyncio.Semaphore(20), # High throughput
68
+ 'public_rpc': asyncio.Semaphore(10), # Protected
69
+ }
 
 
 
 
 
 
 
70
 
71
+ # 3. Timing & Stats Trackers
72
+ self.last_calls = defaultdict(float)
 
 
73
  self.session_stats = defaultdict(int)
74
 
75
+ # 4. Health Monitoring System
 
 
 
76
  self.endpoint_health = defaultdict(lambda: defaultdict(lambda: {
77
  "latency": deque(maxlen=RPC_HEALTH_CHECK_WINDOW),
78
+ "errors": 0,
79
+ "last_error": 0.0,
80
+ "circuit_open": False
81
  }))
82
+
83
+ # 5. Load Configurations & Custom CSV
84
+ self.network_configs = self._initialize_full_network_configs()
85
 
86
+ # 6. Web3 Engine Cache
87
+ self.web3_instances = {}
88
+
89
+ print(f"✅ [RPCManager V3.2] System Ready. Tracking {len(self.network_configs)} networks.")
90
+
91
+ # ==============================================================================
92
+ # 🛠️ Initialization & Config Loading (Full Logic)
93
+ # ==============================================================================
94
+
95
  def _load_rpc_from_csv(self, csv_file_path: str) -> Dict[str, List[str]]:
96
+ """قراءة ملف CSV للنقاط الخاصة ودمجها."""
 
 
 
 
97
  custom_rpcs = defaultdict(list)
98
  if not os.path.exists(csv_file_path):
99
+ print(f"⚠️ [RPCManager] CSV file missing at: {csv_file_path}")
100
  return custom_rpcs
101
 
102
  try:
103
+ with open(csv_file_path, mode='r', encoding='utf-8-sig') as f: # utf-8-sig handles BOM
104
  reader = csv.DictReader(f)
105
+ count = 0
106
  for row in reader:
107
+ # التنظيف والتحقق
108
+ net = row.get('network', '').strip().lower()
109
+ url = row.get('url', '').strip()
110
+
111
+ if net and url and url.startswith(('http://', 'https://')):
112
+ custom_rpcs[net].append(url)
113
+ count += 1
114
+
115
+ print(f" 📂 [CSV Load] Successfully loaded {count} custom endpoints.")
116
  return custom_rpcs
117
  except Exception as e:
118
+ print(f"❌ [CSV Load] Critical Error parsing CSV: {e}")
119
  return defaultdict(list)
120
 
121
+ def _initialize_full_network_configs(self) -> Dict[str, Any]:
122
+ """بناء تكوين الشبكة الكامل بدمج الافتراضيات + المفاتيح + CSV"""
123
+ configs = DEFAULT_NETWORK_CONFIGS.copy()
 
 
 
 
 
124
  custom_rpcs = self._load_rpc_from_csv(CUSTOM_RPC_CSV_FILE)
125
+ infura_key = self.api_keys.get('infura')
126
+
127
+ final_configs = {}
128
+
129
  for network, config in configs.items():
130
+ # Deep Copy لتجنب تعديل الافتراضيات
131
  new_config = config.copy()
132
+ current_endpoints = new_config.get('rpc_endpoints', [])
133
 
134
+ # 1. معالجة Infura Keys
135
+ processed_endpoints = []
136
+ for ep in current_endpoints:
137
+ if "<INFURA_KEY>" in ep:
138
+ if infura_key:
139
+ processed_endpoints.append(ep.replace("<INFURA_KEY>", infura_key))
140
+ # else: skip infura endpoint if no key
141
+ else:
142
+ processed_endpoints.append(ep)
143
 
144
+ # 2. إضافة نقاط CSV (في المقدمة للأولوية)
145
  if network in custom_rpcs:
146
+ csv_points = custom_rpcs[network]
147
+ print(f" [Config] Injecting {len(csv_points)} custom RPCs for {network}")
148
+ processed_endpoints = csv_points + processed_endpoints
149
+
150
+ # 3. إزالة التكرار والحفاظ على الترتيب
151
+ unique_endpoints = []
152
+ seen = set()
153
+ for ep in processed_endpoints:
154
+ if ep not in seen:
155
+ unique_endpoints.append(ep)
156
+ seen.add(ep)
157
+
158
+ # 4. خلط عشوائي جزئي (للنقاط العامة فقط) لضمان توزيع الحمل
159
+ # ملاحظة: نحافظ على أول نقطة إذا كانت من CSV غالباً
160
+ if len(unique_endpoints) > 1:
161
+ first = unique_endpoints[0]
162
+ rest = unique_endpoints[1:]
163
+ random.shuffle(rest)
164
+ unique_endpoints = [first] + rest
165
 
166
+ new_config['rpc_endpoints'] = unique_endpoints
 
167
 
168
+ # 5. حقن مفاتيح المستكشفات (Explorers)
169
  if config.get('explorer'):
170
+ key_name = config['explorer'].get('api_key_name')
171
+ if key_name and key_name in self.api_keys:
172
+ new_config['explorer']['api_key'] = self.api_keys[key_name]
173
+
174
+ final_configs[network] = new_config
175
 
176
+ return final_configs
 
177
 
178
+ # ==============================================================================
179
+ # ⚡ Web3 Engine (High Performance)
180
+ # ==============================================================================
181
+
182
+ def get_web3(self, network: str) -> Optional[AsyncWeb3]:
183
  """
184
+ إرجاع مثيل Web3 حيوي للشبكة.
185
+ يستخدم Cache، وإذا فشل يقوم بتدوير النقاط.
186
  """
187
+ config = self.network_configs.get(network)
188
+ if not config or config.get('type') != 'evm':
189
+ return None
190
+
191
+ # التحقق من الكاش
192
+ if network in self.web3_instances:
193
+ # هنا يمكن إضافة فحص صحة الاتصال (isConnected) إذا لزم الأمر
194
+ return self.web3_instances[network]
195
+
196
+ endpoints = config['rpc_endpoints']
197
+ if not endpoints:
198
+ print(f"❌ [Web3] No endpoints available for {network}")
199
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
+ # محاولة الاتصال (نختار أفضل نقطة متاحة)
202
+ # في بيئة الإنتاج، نفضل النقطة الأولى (التي قد تكون Private Node من CSV)
203
+ endpoint = endpoints[0]
204
+
205
+ try:
206
+ provider = AsyncHTTPProvider(endpoint, request_kwargs={'timeout': 20.0})
207
+ w3 = AsyncWeb3(provider)
208
+
209
+ # إضافة Middleware للشبكات التي تحتاج POA (مثل BSC, Polygon)
210
+ if network in ['bsc', 'polygon', 'avalanche', 'fantom']:
211
+ w3.middleware_onion.inject(async_geth_poa_middleware, layer=0)
212
+
213
+ self.web3_instances[network] = w3
214
+ print(f" 🔌 [Web3] Connected to {network} via {endpoint.split('//')[-1][:20]}...")
215
+ return w3
216
+ except Exception as e:
217
+ print(f"❌ [Web3] Failed to initialize {network}: {e}")
218
+ return None
219
 
220
+ # ==============================================================================
221
+ # 🌐 Raw HTTP RPC (Legacy & Fallback Support)
222
+ # ==============================================================================
223
 
224
+ async def post_rpc(self, network: str, payload: dict, timeout: float = 20.0) -> Optional[Dict]:
225
  """
226
+ إرسال طلب JSON-RPC خام عبر HTTP.
227
+ ضروري للوظائف التي لا تغطيها Web3 أو عند فشل Web3.
228
+ يدعم نظام Circuit Breaker والصحة.
229
  """
230
+ config = self.network_configs.get(network)
231
+ if not config: return None
232
+
233
+ endpoints = config['rpc_endpoints']
234
+ valid_endpoints = self._get_healthy_endpoints(network, endpoints)
235
+
236
+ if not valid_endpoints:
237
+ print(f"❌ [Raw RPC] No healthy endpoints for {network}")
238
+ return None
239
 
240
+ # المحاولة مع أفضل 3 نقاط
241
+ for endpoint in valid_endpoints[:3]:
242
+ ep_name = endpoint.split('//')[-1]
243
+
244
+ # تحديد Semaphore المناسب
245
+ semaphore = self.semaphores['infura'] if 'infura.io' in endpoint else self.semaphores['public_rpc']
246
+
247
+ async with semaphore:
248
+ start_time = time.time()
249
+ try:
250
+ response = await self.http_client.post(endpoint, json=payload, timeout=timeout)
251
+ response.raise_for_status()
252
+ data = response.json()
253
+
254
+ # تحديث الصحة
255
+ latency = time.time() - start_time
256
+ self._update_health(network, endpoint, True, latency)
257
+ self.session_stats['rpc_raw_success'] += 1
258
+
259
+ return data
260
+
261
+ except Exception as e:
262
+ self._update_health(network, endpoint, False, 0.0)
263
+ self.session_stats['rpc_raw_fail'] += 1
264
+ # print(f"⚠️ [Raw RPC] Fail {ep_name}: {str(e)}") # سجل صامت لتفادي الإزعاج
265
+ continue
266
+
267
+ return None
268
+
269
+ def _get_healthy_endpoints(self, network: str, endpoints: List[str]) -> List[str]:
270
+ """تصفية وترتيب النقاط بناءً على الصحة والأخطاء السابقة"""
271
+ now = time.time()
272
+ healthy = []
273
 
274
  for ep in endpoints:
275
+ stats = self.endpoint_health[network][ep]
276
+
277
+ # التحقق من قاطع الدائرة (Circuit Breaker)
278
+ if stats['circuit_open']:
279
+ if now - stats['last_error'] > RPC_CIRCUIT_BREAKER_DURATION:
280
+ stats['circuit_open'] = False
281
+ stats['errors'] = 0
 
282
  else:
283
+ continue # تجاوز هذه النقطة
284
 
285
+ # حساب متوسط التأخير (Latency Score)
286
+ avg_lat = sum(stats['latency']) / len(stats['latency']) if stats['latency'] else 1.0
287
+ healthy.append((ep, avg_lat))
288
+
289
+ # الترتيب حسب الأسرع
290
+ healthy.sort(key=lambda x: x[1])
291
+ return [h[0] for h in healthy]
 
 
 
 
292
 
293
  def _update_health(self, network: str, endpoint: str, success: bool, latency: float):
294
+ stats = self.endpoint_health[network][endpoint]
 
295
  if success:
296
+ stats['latency'].append(latency)
297
+ stats['errors'] = 0
298
+ stats['circuit_open'] = False
299
  else:
300
+ stats['errors'] += 1
301
+ stats['last_error'] = time.time()
302
+ if stats['errors'] >= RPC_ERROR_THRESHOLD:
303
+ stats['circuit_open'] = True
304
+ print(f"🚨 [RPC Breaker] Endpoint suspended: {endpoint.split('//')[-1]}")
305
 
306
+ # ==============================================================================
307
+ # 🌍 External APIs (CoinGecko, Solscan, Moralis)
308
+ # ==============================================================================
309
 
310
+ async def get_coingecko_api(self, path: str, params: dict, retries: int = 1) -> Optional[Dict]:
311
  """
312
+ التعامل الدقيق مع CoinGecko لتفادي 404 و 429.
 
 
313
  """
314
+ full_url = f"{COINGECKO_BASE_URL}{path}"
315
 
316
+ async with self.semaphores['coingecko']:
317
+ # Throttling
318
+ now = time.time()
319
+ diff = now - self.last_calls['coingecko']
320
+ if diff < COINGECKO_REQUEST_DELAY:
321
+ wait_time = COINGECKO_REQUEST_DELAY - diff
322
+ await asyncio.sleep(wait_time)
 
 
 
 
 
 
 
 
323
 
324
+ for attempt in range(retries + 1):
325
+ try:
326
+ self.session_stats['coingecko_total'] += 1
327
+ response = await self.http_client.get(full_url, params=params)
328
+ self.last_calls['coingecko'] = time.time()
329
+
330
+ if response.status_code == 200:
331
+ self.session_stats['coingecko_success'] += 1
332
+ return response.json()
333
+
334
+ elif response.status_code == 429: # Rate Limit
335
+ wait = 10.0 * (attempt + 1)
336
+ print(f"⚠️ [CoinGecko] Rate Limit (429). Waiting {wait}s...")
337
+ await asyncio.sleep(wait)
338
+ continue
339
+
340
+ elif response.status_code == 404:
341
+ # 404 تعني العملة غير موجودة أو المسار خطأ
342
+ # لا نعتبرها خطأ اتصال، بل استجابة فارغة
343
+ return None
344
+
345
+ else:
346
+ response.raise_for_status()
347
+
348
+ except Exception as e:
349
+ self.session_stats['coingecko_fail'] += 1
350
+ print(f"❌ [CoinGecko] Error: {e}")
351
+ return None
352
+ return None
353
 
354
+ async def get_solscan_api(self, path: str, params: dict) -> Optional[Dict]:
355
+ """جلب بيانات Solscan مع التحقق من المفتاح"""
356
+ key = self.api_keys.get('solscan')
357
+ if not key:
358
+ # print("⚠️ [Solscan] Key missing. Request skipped.")
359
  return None
360
 
361
+ base_url = "https://pro-api.solscan.io"
362
+ full_url = f"{base_url}{path}"
363
+ headers = {"token": key, "accept": "application/json"}
364
+
365
+ async with self.semaphores['solscan']:
366
+ # Solscan Throttling
367
+ now = time.time()
368
+ if now - self.last_calls['solscan'] < SOLSCAN_RATE_LIMIT:
369
+ await asyncio.sleep(SOLSCAN_RATE_LIMIT)
370
 
371
+ try:
372
+ self.session_stats['solscan_total'] += 1
373
+ response = await self.http_client.get(full_url, params=params, headers=headers)
374
+ self.last_calls['solscan'] = time.time()
 
375
 
376
+ if response.status_code == 200:
377
+ self.session_stats['solscan_success'] += 1
378
+ return response.json()
379
+ else:
380
+ print(f"⚠️ [Solscan] Error {response.status_code}: {response.text}")
381
+ return None
382
  except Exception as e:
383
+ self.session_stats['solscan_fail'] += 1
384
+ print(f"❌ [Solscan] Exception: {e}")
385
+ return None
 
 
 
 
 
386
 
387
+ async def get_moralis_api(self, params: dict) -> Optional[Dict]:
388
+ """جلب بيانات Moralis"""
389
+ key = self.api_keys.get('moralis')
390
+ if not key: return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
392
+ url = "https://deep-index.moralis.io/api/v2.2/erc20/transfers"
393
+ headers = {"X-API-Key": key, "accept": "application/json"}
 
 
394
 
395
+ async with self.semaphores['moralis']:
396
+ now = time.time()
397
+ if now - self.last_calls['moralis'] < MORALIS_RATE_LIMIT:
398
+ await asyncio.sleep(MORALIS_RATE_LIMIT)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
 
400
+ try:
401
+ self.session_stats['moralis_total'] += 1
402
+ response = await self.http_client.get(url, params=params, headers=headers)
403
+ self.last_calls['moralis'] = time.time()
404
 
405
+ if response.status_code == 200:
406
+ self.session_stats['moralis_success'] += 1
407
+ return response.json()
408
+ return None
409
+ except Exception as e:
410
+ self.session_stats['moralis_fail'] += 1
411
+ return None
412
 
413
+ async def get_scanner_api(self, base_url: str, params: dict) -> Optional[Dict]:
414
+ """Etherscan/BscScan compatible API calls"""
415
+ async with self.semaphores['scanners']:
416
+ try:
417
+ self.session_stats['scanner_total'] += 1
418
+ response = await self.http_client.get(base_url, params=params, timeout=15.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
 
420
+ if response.status_code == 200:
421
+ self.session_stats['scanner_success'] += 1
422
+ return response.json()
423
+ return None
424
+ except Exception as e:
425
+ self.session_stats['scanner_fail'] += 1
426
+ return None
427
+
428
+ # ==============================================================================
429
+ # 📊 Utilities
430
+ # ==============================================================================
431
+
432
+ def reset_session_stats(self):
433
+ self.session_stats = defaultdict(int)
434
+
435
+ def get_session_stats(self):
436
+ return self.session_stats.copy()
437
+
438
+ def get_api_key(self, name: str) -> Optional[str]:
439
+ return self.api_keys.get(name)
440
+
441
+ def get_explorer_config(self, network: str) -> Optional[Dict]:
442
+ return self.network_configs.get(network, {}).get('explorer')
443
+
444
+ def get_network_configs(self) -> Dict:
445
+ return self.network_configs
446
+
447
+ async def close(self):
448
+ """Cleanup resources if needed"""
449
+ # Usually http_client is managed externally, but we can clear caches
450
+ self.web3_instances.clear()
451
+ print("🛑 [RPCManager] Shutting down connection engine.")