iwa 0.0.59__py3-none-any.whl → 0.0.61__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.
@@ -26,6 +26,11 @@ class ChainInterface:
26
26
  DEFAULT_RETRY_DELAY = 1.0 # Base delay between retries (exponential backoff)
27
27
  ROTATION_COOLDOWN_SECONDS = 2.0 # Minimum time between RPC rotations
28
28
 
29
+ # Per-error-type backoff durations (seconds) applied to the offending RPC.
30
+ RATE_LIMIT_BACKOFF = 10.0 # 429 Too Many Requests
31
+ QUOTA_EXCEEDED_BACKOFF = 300.0 # RPC quota exhausted (resets hourly/daily)
32
+ CONNECTION_ERROR_BACKOFF = 30.0 # Timeout / connection refused / DNS
33
+
29
34
  chain: SupportedChain
30
35
 
31
36
  def __init__(self, chain: Union[SupportedChain, str] = None):
@@ -36,10 +41,9 @@ class ChainInterface:
36
41
  chain: SupportedChain = getattr(SupportedChains(), chain.lower())
37
42
 
38
43
  self.chain = chain
39
- # Enforce strict 1.0 RPS limit to prevent synchronization issues
40
- self._rate_limiter = get_rate_limiter(chain.name, rate=1.0, burst=1)
44
+ self._rate_limiter = get_rate_limiter(chain.name, rate=5.0, burst=10)
41
45
  self._current_rpc_index = 0
42
- self._rpc_failure_counts: Dict[int, int] = {}
46
+ self._rpc_backoff_until: Dict[int, float] = {} # index -> monotonic expiry
43
47
  self._last_rotation_time = 0.0 # Monotonic timestamp of last rotation
44
48
 
45
49
  if self.chain.rpc and self.chain.rpc.startswith("http://"):
@@ -50,9 +54,41 @@ class ChainInterface:
50
54
 
51
55
  self._initial_block = 0
52
56
  self._rotation_lock = threading.Lock()
53
- self._session = requests.Session()
57
+ self._session = self._create_session()
58
+
59
+ # Enrich with public RPCs from ChainList (skip for Tenderly vNets)
60
+ if not self.is_tenderly:
61
+ self._enrich_rpcs_from_chainlist()
62
+
54
63
  self._init_web3()
55
64
 
65
+ def _create_session(self) -> requests.Session:
66
+ """Create a requests Session with bounded connection pooling.
67
+
68
+ Configures the session with limited pool sizes to prevent file
69
+ descriptor exhaustion during RPC rotations. Connections are reused
70
+ within the pool but won't accumulate unboundedly.
71
+ """
72
+ session = requests.Session()
73
+ # Limit pool size: we only talk to one RPC at a time, but may rotate
74
+ # through multiple during the session lifetime. Keep modest limits.
75
+ adapter = requests.adapters.HTTPAdapter(
76
+ pool_connections=5, # Max different hosts to keep connections to
77
+ pool_maxsize=10, # Max connections per host
78
+ )
79
+ session.mount("https://", adapter)
80
+ session.mount("http://", adapter)
81
+ return session
82
+
83
+ def close(self) -> None:
84
+ """Close the session and release all connections.
85
+
86
+ Call this when the ChainInterface is no longer needed to ensure
87
+ proper cleanup of network resources.
88
+ """
89
+ if hasattr(self, "_session") and self._session:
90
+ self._session.close()
91
+
56
92
  @property
57
93
  def current_rpc(self) -> str:
58
94
  """Get the current active RPC URL."""
@@ -229,6 +265,63 @@ class ChainInterface:
229
265
  ]
230
266
  return any(signal in err_text for signal in gas_signals)
231
267
 
268
+ def _is_quota_exceeded_error(self, error: Exception) -> bool:
269
+ """Check if the RPC's usage quota has been exhausted.
270
+
271
+ JSON-RPC code -32001 with messages like "Exceeded the quota usage"
272
+ indicates the provider's daily/hourly quota is spent. This is NOT
273
+ a transient 429 rate-limit; the RPC will reject ALL requests until
274
+ the quota resets, so it must be backed off for a long period.
275
+ """
276
+ err_text = str(error).lower()
277
+ quota_signals = [
278
+ "exceeded the quota",
279
+ "exceeded quota",
280
+ "quota usage",
281
+ "quota exceeded",
282
+ "allowance exceeded",
283
+ ]
284
+ return any(signal in err_text for signal in quota_signals)
285
+
286
+ # -- ChainList enrichment ----------------------------------------------
287
+
288
+ MAX_RPCS = 10 # Cap total RPCs per chain
289
+
290
+ def _enrich_rpcs_from_chainlist(self) -> None:
291
+ """Add validated public RPCs from ChainList to the rotation pool."""
292
+ if len(self.chain.rpcs) >= self.MAX_RPCS:
293
+ return
294
+
295
+ try:
296
+ from iwa.core.chainlist import ChainlistRPC
297
+
298
+ chainlist = ChainlistRPC()
299
+ extra = chainlist.get_validated_rpcs(
300
+ self.chain.chain_id,
301
+ existing_rpcs=self.chain.rpcs,
302
+ max_results=self.MAX_RPCS - len(self.chain.rpcs),
303
+ )
304
+ if extra:
305
+ self.chain.rpcs.extend(extra)
306
+ logger.info(
307
+ f"Enriched {self.chain.name} with {len(extra)} "
308
+ f"ChainList RPCs (total: {len(self.chain.rpcs)})"
309
+ )
310
+ except Exception as e:
311
+ logger.debug(
312
+ f"ChainList enrichment failed for {self.chain.name}: {e}"
313
+ )
314
+
315
+ # -- Per-RPC health tracking ------------------------------------------
316
+
317
+ def _mark_rpc_backoff(self, index: int, seconds: float) -> None:
318
+ """Mark an RPC as temporarily unavailable for *seconds*."""
319
+ self._rpc_backoff_until[index] = time.monotonic() + seconds
320
+
321
+ def _is_rpc_healthy(self, index: int) -> bool:
322
+ """Return True if the RPC at *index* is not in backoff."""
323
+ return time.monotonic() >= self._rpc_backoff_until.get(index, 0.0)
324
+
232
325
  def _handle_rpc_error(self, error: Exception) -> Dict[str, Union[bool, int]]:
233
326
  """Handle RPC errors with smart rotation and retry logic."""
234
327
  result: Dict[str, Union[bool, int]] = {
@@ -237,6 +330,7 @@ class ChainInterface:
237
330
  "is_server_error": self._is_server_error(error),
238
331
  "is_gas_error": self._is_gas_error(error),
239
332
  "is_tenderly_quota": self._is_tenderly_quota_exceeded(error),
333
+ "is_quota_exceeded": self._is_quota_exceeded_error(error),
240
334
  "rotated": False,
241
335
  "should_retry": False,
242
336
  }
@@ -251,19 +345,33 @@ class ChainInterface:
251
345
  "Run 'uv run -m iwa.tools.reset_tenderly' to reset."
252
346
  )
253
347
 
254
- self._rpc_failure_counts[self._current_rpc_index] = (
255
- self._rpc_failure_counts.get(self._current_rpc_index, 0) + 1
348
+ # Determine if we need to rotate and what backoff to apply.
349
+ should_rotate = (
350
+ result["is_rate_limit"]
351
+ or result["is_connection_error"]
352
+ or result["is_quota_exceeded"]
256
353
  )
257
354
 
258
- should_rotate = result["is_rate_limit"] or result["is_connection_error"]
259
-
260
355
  if should_rotate:
261
- error_type = "rate limit" if result["is_rate_limit"] else "connection"
262
- # Extract the original URL from the error message for clarity
263
- error_msg = str(error)
356
+ failed_index = self._current_rpc_index
357
+
358
+ # Apply per-RPC backoff so smart rotation skips this RPC.
359
+ if result["is_quota_exceeded"]:
360
+ error_type = "quota exceeded"
361
+ self._mark_rpc_backoff(failed_index, self.QUOTA_EXCEEDED_BACKOFF)
362
+ elif result["is_rate_limit"]:
363
+ error_type = "rate limit"
364
+ self._mark_rpc_backoff(failed_index, self.RATE_LIMIT_BACKOFF)
365
+ # Brief global backoff so other threads don't immediately flood
366
+ # the same (now backed-off) RPC before rotation takes effect.
367
+ self._rate_limiter.trigger_backoff(seconds=2.0)
368
+ else:
369
+ error_type = "connection"
370
+ self._mark_rpc_backoff(failed_index, self.CONNECTION_ERROR_BACKOFF)
371
+
264
372
  logger.warning(
265
373
  f"RPC {error_type} error on {self.chain.name} "
266
- f"(current RPC #{self._current_rpc_index}): {error_msg}"
374
+ f"(RPC #{failed_index}): {error}"
267
375
  )
268
376
 
269
377
  if self.rotate_rpc():
@@ -271,14 +379,11 @@ class ChainInterface:
271
379
  result["should_retry"] = True
272
380
  logger.info(f"Rotated to RPC #{self._current_rpc_index} for {self.chain.name}")
273
381
  else:
274
- if result["is_rate_limit"]:
275
- # Rotation was skipped (cooldown or single RPC) - still allow retry with current RPC
276
- # We don't trigger backoff here because that would block ALL threads.
277
- # Instead, we let the individual thread retry (which has its own exponential backoff).
278
- result["should_retry"] = True
279
- logger.info(
280
- f"RPC rotation skipped, retrying with current RPC #{self._current_rpc_index}"
281
- )
382
+ # Rotation skipped (cooldown or single RPC) - still allow retry
383
+ result["should_retry"] = True
384
+ logger.info(
385
+ f"RPC rotation skipped, retrying with current RPC #{self._current_rpc_index}"
386
+ )
282
387
 
283
388
  elif result["is_server_error"]:
284
389
  logger.warning(f"Server error on {self.chain.name}: {error}")
@@ -291,30 +396,40 @@ class ChainInterface:
291
396
  return result
292
397
 
293
398
  def rotate_rpc(self) -> bool:
294
- """Rotate to the next available RPC."""
399
+ """Rotate to the next healthy RPC, skipping those in backoff."""
295
400
  with self._rotation_lock:
296
- if not self.chain.rpcs or len(self.chain.rpcs) <= 1:
401
+ n = len(self.chain.rpcs) if self.chain.rpcs else 0
402
+ if n <= 1:
297
403
  return False
298
404
 
299
405
  # Cooldown: prevent cascade rotations from in-flight requests
300
406
  now = time.monotonic()
301
- elapsed = now - self._last_rotation_time
302
- if elapsed < self.ROTATION_COOLDOWN_SECONDS:
303
- logger.debug(
304
- f"RPC rotation skipped for {self.chain.name} (cooldown active, "
305
- f"{self.ROTATION_COOLDOWN_SECONDS - elapsed:.1f}s remaining)"
306
- )
407
+ if now - self._last_rotation_time < self.ROTATION_COOLDOWN_SECONDS:
307
408
  return False
308
409
 
309
- # Simple Round Robin rotation
310
- self._current_rpc_index = (self._current_rpc_index + 1) % len(self.chain.rpcs)
311
- # Internal call to _init_web3 already expects to be under lock if called from here,
312
- # but _init_web3 itself doesn't have a lock. Let's make it consistent.
410
+ # Try each other RPC in round-robin order, preferring healthy ones.
411
+ best: Optional[int] = None
412
+ for offset in range(1, n):
413
+ candidate = (self._current_rpc_index + offset) % n
414
+ if self._is_rpc_healthy(candidate):
415
+ best = candidate
416
+ break
417
+
418
+ if best is None:
419
+ # All RPCs are in backoff — pick the one whose backoff expires soonest.
420
+ best = min(
421
+ (i for i in range(n) if i != self._current_rpc_index),
422
+ key=lambda i: self._rpc_backoff_until.get(i, 0.0),
423
+ )
424
+
425
+ self._current_rpc_index = best
313
426
  self._init_web3_under_lock()
314
427
  self._last_rotation_time = now
315
428
 
429
+ healthy_tag = "" if self._is_rpc_healthy(best) else " (still in backoff)"
316
430
  logger.info(
317
- f"Rotated RPC for {self.chain.name} to index {self._current_rpc_index}: {self.chain.rpcs[self._current_rpc_index]}"
431
+ f"Rotated RPC for {self.chain.name} to index {best}: "
432
+ f"{self.chain.rpcs[best]}{healthy_tag}"
318
433
  )
319
434
  return True
320
435
 
@@ -583,6 +698,6 @@ class ChainInterface:
583
698
  return self.chain.contracts.get(contract_name)
584
699
 
585
700
  def reset_rpc_failure_counts(self):
586
- """Reset RPC failure tracking. Call periodically to allow retrying failed RPCs."""
587
- self._rpc_failure_counts.clear()
588
- logger.debug("Reset RPC failure counts")
701
+ """Reset RPC backoff tracking. Call periodically to allow retrying backed-off RPCs."""
702
+ self._rpc_backoff_until.clear()
703
+ logger.debug("Reset RPC backoff tracking")
iwa/core/chain/manager.py CHANGED
@@ -36,3 +36,11 @@ class ChainInterfaces:
36
36
  for name, interface in self.items():
37
37
  results[name] = interface.check_rpc_health()
38
38
  return results
39
+
40
+ def close_all(self) -> None:
41
+ """Close all chain interface sessions.
42
+
43
+ Call this at application shutdown to release network resources.
44
+ """
45
+ for _, interface in self.items():
46
+ interface.close()
@@ -128,9 +128,22 @@ class RateLimitedEth:
128
128
  # Helper sets for efficient lookup
129
129
  RPC_METHODS = READ_METHODS | WRITE_METHODS
130
130
 
131
- DEFAULT_READ_RETRIES = 3
131
+ DEFAULT_READ_RETRIES = 1 # Keep low; ChainInterface.with_retry handles cross-RPC retries
132
132
  DEFAULT_READ_RETRY_DELAY = 0.5
133
133
 
134
+ # Only retry errors that are clearly transient network issues.
135
+ # Rate-limit / quota / server errors propagate up to with_retry for rotation.
136
+ TRANSIENT_SIGNALS = (
137
+ "timeout",
138
+ "timed out",
139
+ "connection reset",
140
+ "connection refused",
141
+ "connection aborted",
142
+ "broken pipe",
143
+ "eof",
144
+ "remote end closed",
145
+ )
146
+
134
147
  def __init__(self, web3_eth, rate_limiter: RPCRateLimiter, chain_interface: "ChainInterface"):
135
148
  """Initialize RateLimitedEth wrapper."""
136
149
  object.__setattr__(self, "_eth", web3_eth)
@@ -187,20 +200,36 @@ class RateLimitedEth:
187
200
  return wrapper
188
201
 
189
202
  def _execute_with_retry(self, method, method_name, *args, **kwargs):
190
- """Execute read operation with retry logic."""
203
+ """Execute a read operation with limited retry for transient errors.
204
+
205
+ Only connection-level failures (timeout, reset, broken pipe) are
206
+ retried here. Rate-limit, quota, and server errors propagate up
207
+ to ``ChainInterface.with_retry`` which handles RPC rotation.
208
+ This avoids the double-retry amplification that previously caused
209
+ up to 4x7 = 28 RPC requests per logical call.
210
+ """
191
211
  for attempt in range(self.DEFAULT_READ_RETRIES + 1):
192
212
  try:
193
213
  return method(*args, **kwargs)
194
214
  except Exception as e:
195
- # Use chain interface to handle error (logging, rotation, etc.)
196
- result = self._chain_interface._handle_rpc_error(e)
215
+ if attempt >= self.DEFAULT_READ_RETRIES:
216
+ raise
197
217
 
198
- if not result["should_retry"] or attempt >= self.DEFAULT_READ_RETRIES:
218
+ # Only retry clearly transient network errors.
219
+ err_text = str(e).lower()
220
+ if not any(signal in err_text for signal in self.TRANSIENT_SIGNALS):
199
221
  raise
200
222
 
223
+ # Re-acquire a rate-limiter token before retrying.
224
+ if not self._rate_limiter.acquire(timeout=30.0):
225
+ raise TimeoutError(
226
+ f"Rate limit timeout for retry of {method_name}"
227
+ ) from e
228
+
201
229
  delay = self.DEFAULT_READ_RETRY_DELAY * (2**attempt)
202
230
  logger.debug(
203
- f"{method_name} attempt {attempt + 1} failed, retrying in {delay:.1f}s..."
231
+ f"{method_name} attempt {attempt + 1} failed (transient), "
232
+ f"retrying in {delay:.1f}s..."
204
233
  )
205
234
  time.sleep(delay)
206
235
 
iwa/core/chainlist.py CHANGED
@@ -2,12 +2,158 @@
2
2
 
3
3
  import json
4
4
  import time
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
6
  from dataclasses import dataclass
6
- from typing import Any, Dict, List, Optional
7
+ from typing import Any, Dict, List, Optional, Tuple
7
8
 
8
9
  import requests
9
10
 
10
11
  from iwa.core.constants import CACHE_DIR
12
+ from iwa.core.utils import configure_logger
13
+
14
+ logger = configure_logger()
15
+
16
+ # -- RPC probing constants --------------------------------------------------
17
+
18
+ MAX_CHAINLIST_CANDIDATES = 15 # Probe at most this many candidates
19
+ PROBE_TIMEOUT = 5.0 # Seconds per probe request
20
+ MAX_BLOCK_LAG = 10 # Blocks behind majority → considered stale
21
+
22
+
23
+ def _normalize_url(url: str) -> str:
24
+ """Normalize an RPC URL for deduplication (lowercase, strip trailing slash)."""
25
+ return url.rstrip("/").lower()
26
+
27
+
28
+ def _is_template_url(url: str) -> bool:
29
+ """Return True if the URL contains template variables requiring an API key."""
30
+ return "${" in url or "{" in url
31
+
32
+
33
+ def probe_rpc(
34
+ url: str,
35
+ timeout: float = PROBE_TIMEOUT,
36
+ session: Optional[requests.Session] = None,
37
+ ) -> Optional[Tuple[str, float, int]]:
38
+ """Probe an RPC endpoint with eth_blockNumber.
39
+
40
+ Returns ``(url, latency_ms, block_number)`` on success, or ``None``
41
+ if the endpoint is unreachable, slow, or returns invalid data.
42
+
43
+ Args:
44
+ url: The RPC endpoint URL to probe.
45
+ timeout: Request timeout in seconds.
46
+ session: Optional requests.Session for connection reuse. If None,
47
+ creates a temporary session that is properly closed.
48
+
49
+ """
50
+ # Use provided session or create temporary one with proper cleanup
51
+ own_session = session is None
52
+ if own_session:
53
+ session = requests.Session()
54
+
55
+ try:
56
+ start = time.monotonic()
57
+ resp = session.post(
58
+ url,
59
+ json={"jsonrpc": "2.0", "method": "eth_blockNumber", "params": [], "id": 1},
60
+ timeout=timeout,
61
+ )
62
+ latency_ms = (time.monotonic() - start) * 1000
63
+ data = resp.json()
64
+ block_hex = data.get("result")
65
+ if not block_hex or not isinstance(block_hex, str) or block_hex == "0x0":
66
+ return None
67
+ return (url, latency_ms, int(block_hex, 16))
68
+ except Exception:
69
+ return None
70
+ finally:
71
+ if own_session:
72
+ session.close()
73
+
74
+
75
+ def _filter_candidates(
76
+ nodes: "List[RPCNode]",
77
+ existing_normalized: set,
78
+ ) -> List[str]:
79
+ """Filter ChainList nodes to usable HTTPS candidates."""
80
+ candidates: List[str] = []
81
+ for node in nodes:
82
+ url = node.url
83
+ if not url.startswith("https://"):
84
+ continue
85
+ if _is_template_url(url):
86
+ continue
87
+ if _normalize_url(url) in existing_normalized:
88
+ continue
89
+ candidates.append(url)
90
+ if len(candidates) >= MAX_CHAINLIST_CANDIDATES:
91
+ break
92
+ return candidates
93
+
94
+
95
+ def _probe_candidates(
96
+ candidates: List[str],
97
+ ) -> List[Tuple[str, float, int]]:
98
+ """Probe a list of RPC URLs in parallel, returning successful results.
99
+
100
+ Uses a shared session for all probes to enable connection pooling and
101
+ ensure proper cleanup of all connections when probing completes.
102
+ """
103
+ results: List[Tuple[str, float, int]] = []
104
+ # Use a shared session with connection pooling for all probes
105
+ # This prevents FD leaks from individual probe connections
106
+ with requests.Session() as session:
107
+ # Configure connection pool size to match our max workers
108
+ adapter = requests.adapters.HTTPAdapter(
109
+ pool_connections=10,
110
+ pool_maxsize=10,
111
+ max_retries=0, # No retries - we handle failure gracefully
112
+ )
113
+ session.mount("https://", adapter)
114
+ session.mount("http://", adapter)
115
+
116
+ with ThreadPoolExecutor(max_workers=min(len(candidates), 10)) as pool:
117
+ futures = {
118
+ pool.submit(probe_rpc, url, PROBE_TIMEOUT, session): url
119
+ for url in candidates
120
+ }
121
+ for future in as_completed(futures, timeout=15):
122
+ try:
123
+ result = future.result()
124
+ if result is not None:
125
+ results.append(result)
126
+ except Exception:
127
+ pass
128
+ # Session is closed here via context manager, releasing all connections
129
+ return results
130
+
131
+
132
+ def _rank_and_select(
133
+ results: List[Tuple[str, float, int]],
134
+ candidates: List[str],
135
+ chain_id: int,
136
+ max_results: int,
137
+ ) -> List[str]:
138
+ """Rank probed RPCs by latency, filtering stale ones."""
139
+ blocks = sorted(r[2] for r in results)
140
+ median_block = blocks[len(blocks) // 2]
141
+
142
+ valid = [
143
+ (url, latency)
144
+ for url, latency, block in results
145
+ if median_block - block <= MAX_BLOCK_LAG
146
+ ]
147
+ valid.sort(key=lambda x: x[1])
148
+
149
+ selected = [url for url, _ in valid[:max_results]]
150
+ if selected:
151
+ logger.info(
152
+ f"ChainList: validated {len(selected)}/{len(candidates)} "
153
+ f"candidates for chain {chain_id} "
154
+ f"(median block: {median_block})"
155
+ )
156
+ return selected
11
157
 
12
158
 
13
159
  @dataclass
@@ -50,11 +196,12 @@ class ChainlistRPC:
50
196
  except Exception as e:
51
197
  print(f"Error reading Chainlist cache: {e}")
52
198
 
53
- # 2. Fetch from remote
199
+ # 2. Fetch from remote (use session context for proper cleanup)
54
200
  try:
55
- response = requests.get(self.URL, timeout=10)
56
- response.raise_for_status()
57
- self._data = response.json()
201
+ with requests.Session() as session:
202
+ response = session.get(self.URL, timeout=10)
203
+ response.raise_for_status()
204
+ self._data = response.json()
58
205
 
59
206
  # 3. Update local cache
60
207
  if self._data:
@@ -119,3 +266,34 @@ class ChainlistRPC:
119
266
  for node in rpcs
120
267
  if node.url.startswith("wss://") or node.url.startswith("ws://")
121
268
  ]
269
+
270
+ def get_validated_rpcs(
271
+ self,
272
+ chain_id: int,
273
+ existing_rpcs: List[str],
274
+ max_results: int = 5,
275
+ ) -> List[str]:
276
+ """Return ChainList RPCs filtered, probed, and sorted by quality.
277
+
278
+ 1. Fetch HTTPS RPCs from ChainList for *chain_id*.
279
+ 2. Filter out template URLs, duplicates of *existing_rpcs*, and
280
+ websocket endpoints.
281
+ 3. Probe the top candidates in parallel with ``eth_blockNumber``.
282
+ 4. Discard RPCs that are stale (block number lagging behind majority).
283
+ 5. Return up to *max_results* URLs sorted by latency (fastest first).
284
+ """
285
+ nodes = self.get_rpcs(chain_id)
286
+ if not nodes:
287
+ return []
288
+
289
+ existing_normalized = {_normalize_url(u) for u in existing_rpcs}
290
+ candidates = _filter_candidates(nodes, existing_normalized)
291
+ if not candidates:
292
+ return []
293
+
294
+ results = _probe_candidates(candidates)
295
+ if not results:
296
+ return []
297
+
298
+ selected = _rank_and_select(results, candidates, chain_id, max_results)
299
+ return selected
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.59
3
+ Version: 0.0.61
4
4
  Summary: A secure, modular, and plugin-based framework for crypto agents and ops
5
5
  Requires-Python: <4.0,>=3.12
6
6
  Description-Content-Type: text/markdown
@@ -1,7 +1,7 @@
1
1
  iwa/__init__.py,sha256=vu12UytYNREtMRvIWp6AfV1GgUe53XWwCMhYyqKAPgo,19
2
2
  iwa/__main__.py,sha256=eJU5Uxeu9Y7shWg5dt5Mcq0pMC4wFVNWjeYGKSf4Apw,88
3
3
  iwa/core/__init__.py,sha256=GJv4LJOXeZ3hgGvbt5I6omkoFkP2A9qhHjpDlOep9ik,24
4
- iwa/core/chainlist.py,sha256=bcbv1P9R-RiCghullse0qVmqkTs1l4_ZOkOHZ0MzjtI,4097
4
+ iwa/core/chainlist.py,sha256=OraylOuaGXR65p7HNkEssjQzQe5as1WxZoRlxQFYVho,10187
5
5
  iwa/core/cli.py,sha256=Qo0SXvgKFOd3Ru-LnX5zEXIaR7r3uwYoqwPhVShEqiQ,8315
6
6
  iwa/core/constants.py,sha256=_CYUVQpR--dRPuxotsmbzQE-22y61tlnjUD7IhlvVVA,997
7
7
  iwa/core/db.py,sha256=WI-mP0tQAmwFPeEi9w7RCa_Mcf_zBfd_7JcbHJwU1aU,10377
@@ -23,10 +23,10 @@ iwa/core/utils.py,sha256=FTYpIdQ1wnugD4lYU4TQ7d7_TlDs4CTUIhEpHGEJph4,4281
23
23
  iwa/core/wallet.py,sha256=xSGFOK5Wzh-ctLGhBMK1BySlXN0Ircpztyk1an21QiQ,13129
24
24
  iwa/core/chain/__init__.py,sha256=XJMmn0ed-_aVkY2iEMKpuTxPgIKBd41dexSVmEZTa-o,1604
25
25
  iwa/core/chain/errors.py,sha256=9SEbhxZ-qASPkzt-DoI51qq0GRJVqRgqgL720gO7a64,1275
26
- iwa/core/chain/interface.py,sha256=FNb_M1Hl1FlgyC3FUPKH91RW7tF-wKJXQP4iOI14vGg,23798
27
- iwa/core/chain/manager.py,sha256=cFEzh6pK5OyVhjhpeMAqhc9RnRDQR1DjIGiGKp-FXBI,1159
26
+ iwa/core/chain/interface.py,sha256=4VA2qRUZ40J38l7edaeGTlOnwcXLxZxFPo1Flitzl3M,28270
27
+ iwa/core/chain/manager.py,sha256=XHwn7ciapFCZVk0rPSJopUqM5Wu3Kpp6XrenkgTE1HA,1397
28
28
  iwa/core/chain/models.py,sha256=WUhAighMKcFdbAUkPU_3dkGbWyAUpRJqXMHLcWFC1xg,5261
29
- iwa/core/chain/rate_limiter.py,sha256=6XnaB6i3Tvf-6YD4L-YBeJnKjAIuAxBGDvTmm1dQDTM,7924
29
+ iwa/core/chain/rate_limiter.py,sha256=Ps1MrR4HHtylxgUAawe6DoC9tuqKagjQdKulqcJD2gs,9093
30
30
  iwa/core/contracts/__init__.py,sha256=P5GFY_pnuI02teqVY2U0t98bn1_SSPAbcAzRMpCdTi4,34
31
31
  iwa/core/contracts/cache.py,sha256=vN7ArNhNsSDr1rYHDMWsMm6VbSszBt4Xej9MeI-rkgc,4452
32
32
  iwa/core/contracts/contract.py,sha256=TLZGF7BtMl2fr92B80Gp3ttnP4hJsdAG-raaFZiNLO8,13255
@@ -166,7 +166,7 @@ iwa/web/tests/test_web_endpoints.py,sha256=vA25YghHNB23sbmhD4ciesn_f_okSq0tjlkrS
166
166
  iwa/web/tests/test_web_olas.py,sha256=0CVSsrncOeJ3x0ECV7mVLQV_CXZRrOqGiVjgLIi6hZ8,16308
167
167
  iwa/web/tests/test_web_swap.py,sha256=7A4gBJFL01kIXPtW1E1J17SCsVc_0DmUn-R8kKrnnVA,2974
168
168
  iwa/web/tests/test_web_swap_coverage.py,sha256=zGNrzlhZ_vWDCvWmLcoUwFgqxnrp_ACbo49AtWBS_Kw,5584
169
- iwa-0.0.59.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
169
+ iwa-0.0.61.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
170
170
  tests/legacy_cow.py,sha256=oOkZvIxL70ReEoD9oHQbOD5GpjIr6AGNHcOCgfPlerU,8389
171
171
  tests/legacy_safe.py,sha256=AssM2g13E74dNGODu_H0Q0y412lgqsrYnEzI97nm_Ts,2972
172
172
  tests/legacy_transaction_retry_logic.py,sha256=D9RqZ7DBu61Xr2djBAodU2p9UE939LL-DnQXswX5iQk,1497
@@ -176,8 +176,9 @@ tests/legacy_web.py,sha256=q2ERIriaDHT3Q8axG2N3ucO7f2VSvV_WkuPR00DVko4,8577
176
176
  tests/test_account_service.py,sha256=g_AIVT2jhlvUtbFTaCd-d15x4CmXJQaV66tlAgnaXwY,3745
177
177
  tests/test_balance_service.py,sha256=wcuCOVszxPy8nPkldAVcEiygcOK3BuQt797fqAJvbp4,4979
178
178
  tests/test_chain.py,sha256=VZoidSojWyt1y4mQdZdoZsjuuDZjLC6neTC-2SF_Q7I,13957
179
- tests/test_chain_interface.py,sha256=Wu0q0sREtmYBp7YvWrBIrrSTtqeQj18oJp2VmMUEMec,8312
179
+ tests/test_chain_interface.py,sha256=bgqGM8wJGZjc-BOX6i0K4sh06KCJl-6UAvrwl8x24lA,8324
180
180
  tests/test_chain_interface_coverage.py,sha256=fvrVvw8-DMwdsSFKQHUhpbfutrVRxnnTc-tjB7Bb-jo,3327
181
+ tests/test_chainlist_enrichment.py,sha256=P7WctRhZ0sBTlsHsJXj5hv97CzgaEH556Nir2J8vWpg,12928
181
182
  tests/test_cli.py,sha256=Pl4RC2xp1omiJUnL3Dza6pCmIoO29LJ0vGw33_ZpT5c,3980
182
183
  tests/test_contract.py,sha256=tApHAxsfKGawYJWA9PhTNrOZUE0VVAq79ruIe3KxeWY,14412
183
184
  tests/test_db.py,sha256=dmbrupj0qlUeiiycZ2mzMFjf7HrDa6tcqMPY8zpiKIk,5710
@@ -194,15 +195,15 @@ tests/test_models.py,sha256=1bEfPiDVgEdtwFEzwecSPAHjCF8kjOPSMeQExJ7eCJ4,7107
194
195
  tests/test_monitor.py,sha256=dRVS6EkTwfvGEOg7t0dVhs6M3oEZExBH7iBZe6hmk4M,7261
195
196
  tests/test_multisend.py,sha256=IvXpwnC5xSDRCyCDGcMdO3L-eQegvdjAzHZB0FoVFUI,2685
196
197
  tests/test_plugin_service.py,sha256=ZEe37kV_sv4Eb04032O1hZIoo9yf5gJo83ks7Grzrng,3767
197
- tests/test_rate_limiter.py,sha256=gC-mVsTCqGbBoUxAllY_WS9kl12rBHQv7MNr1zdUdGQ,7334
198
- tests/test_rate_limiter_retry.py,sha256=gU4AJk1s39HvdcbGeIoIrr1M7p6WMba_n2y8eFNSXc8,4261
198
+ tests/test_rate_limiter.py,sha256=XDN22HWs85OicBpQ9zgHRnoJ1VMola_AOkobqp83dfs,7444
199
+ tests/test_rate_limiter_retry.py,sha256=Yq7Ik2r8VIYgPdlSN2tYbdA0ngrB37ZPimfkZkh9Cvk,4568
199
200
  tests/test_reset_tenderly.py,sha256=GVoqbDT3n4_GnlKF5Lx-8ew15jT8I2hIPdTulQDb6dI,7215
200
201
  tests/test_rpc_efficiency.py,sha256=mNuCoa5r6lSEyTqcRX98oz-huoKMTUlKM2UcOHlTQ6M,3745
201
- tests/test_rpc_rate_limit.py,sha256=Eo-Nr_7p8jERtouCtKuEmcSzACeiVUb33kt7BeTo4uA,1011
202
+ tests/test_rpc_rate_limit.py,sha256=3P_Nd9voFmz-4r_Et-vw8W-Esbq5elSYmRBSOtJGx1Y,1014
202
203
  tests/test_rpc_rotation.py,sha256=a1cFKsf0fo-73_MSDnTuU6Zpv7bJHjrCVu3ANe8PXDU,12541
203
204
  tests/test_rpc_view.py,sha256=sgZ53KEHl8VGb7WKYa0VI7Cdxbf8JH1SdroHYbWHjfQ,2031
204
205
  tests/test_safe_coverage.py,sha256=KBxKz64XkK8CgN0N0LTNVKakf8Wg8EpghcBlLmDFmLs,6119
205
- tests/test_safe_executor.py,sha256=V3ovBRY1lOuW5rm8rpm5Ns7jb-rgmHKHpz9pMTqz6c4,14448
206
+ tests/test_safe_executor.py,sha256=HYdyWdoKs3xK-1wJ9QGcLx58Dk2GV4-IpDqE9hUhA8I,22149
206
207
  tests/test_safe_integration.py,sha256=WWAKDio3N-CFyr5RRvphbOPdu3TI9WSM8IesfbFbvWQ,5363
207
208
  tests/test_safe_service.py,sha256=5ULlj0fPZRwg-4fCBJplhm4Msr_Beof7W-Zf_JljZc8,5782
208
209
  tests/test_service_manager_integration.py,sha256=I_BLUzEKrVTyg_8jqsUK0oFD3aQVPCRJ7z0gY8P-j04,2354
@@ -211,7 +212,7 @@ tests/test_service_transaction.py,sha256=IeqYhmRD-pIXffBJrBQwfPx-qnfNEJs0iPM3eCb
211
212
  tests/test_staking_router.py,sha256=cnOtwWeQPu09kecVhlCf1WA4ONqs13OcQJhJCx2EOPY,3067
212
213
  tests/test_staking_simple.py,sha256=NHyZ1pcVQEJGFiGseC5m6Y9Y6FJGnRIFJUwhd1hAV9g,1138
213
214
  tests/test_tables.py,sha256=1KQHgxuizoOrRxpubDdnzk9iaU5Lwyp3bcWP_hZD5uU,2686
214
- tests/test_transaction_service.py,sha256=TXhIleUNOnp3DXi-RrKJ1Y_6dA6de5TQLOc9qndMHm4,5765
215
+ tests/test_transaction_service.py,sha256=q2IQ6cJ6sZtzc_pVCM_dv0vW7LW2sONNrK5Pvrm63rU,12816
215
216
  tests/test_transfer_multisend.py,sha256=PErjNqNwN66TMh4oVa307re64Ucccg1LkXqB0KlkmsI,6677
216
217
  tests/test_transfer_native.py,sha256=cDbb4poV_veIw6eHpokrHe9yUndOjA6rQhrHd_IY3HQ,7445
217
218
  tests/test_transfer_security.py,sha256=gdpC6ybdXQbQgILbAQ0GqjWdwn9AJRNR3B_7TYg0NxI,3617
@@ -222,8 +223,8 @@ tests/test_utils.py,sha256=vkP49rYNI8BRzLpWR3WnKdDr8upeZjZcs7Rx0pjbQMo,1292
222
223
  tests/test_workers.py,sha256=MInwdkFY5LdmFB3o1odIaSD7AQZb3263hNafO1De5PE,2793
223
224
  tools/create_and_stake_service.py,sha256=1xwy_bJQI1j9yIQ968Oc9Db_F6mk1659LuuZntTASDE,3742
224
225
  tools/verify_drain.py,sha256=PkMjblyOOAuQge88FwfEzRtCYeEtJxXhPBmtQYCoQ-8,6743
225
- iwa-0.0.59.dist-info/METADATA,sha256=FZrh67solKvQcjzWzdns2HPQGMwPZs0B6_Z2rK7hLJA,7337
226
- iwa-0.0.59.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
227
- iwa-0.0.59.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
228
- iwa-0.0.59.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
229
- iwa-0.0.59.dist-info/RECORD,,
226
+ iwa-0.0.61.dist-info/METADATA,sha256=FLMOGU9L47jQQ45Cd7i64bfZNXCtNidn1ryV4-uBjCA,7337
227
+ iwa-0.0.61.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
228
+ iwa-0.0.61.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
229
+ iwa-0.0.61.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
230
+ iwa-0.0.61.dist-info/RECORD,,
@@ -201,10 +201,10 @@ def test_is_tenderly_property():
201
201
 
202
202
 
203
203
  def test_reset_rpc_failure_counts(mock_chain_interface):
204
- """Test resetting failure counts."""
204
+ """Test resetting backoff tracking."""
205
205
  interface, _ = mock_chain_interface
206
- interface._rpc_failure_counts = {0: 5, 1: 3}
206
+ interface._rpc_backoff_until = {0: 99999.0, 1: 99999.0}
207
207
 
208
208
  interface.reset_rpc_failure_counts()
209
209
 
210
- assert interface._rpc_failure_counts == {}
210
+ assert interface._rpc_backoff_until == {}