iwa 0.0.60__py3-none-any.whl → 0.0.62__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.
@@ -54,9 +54,41 @@ class ChainInterface:
54
54
 
55
55
  self._initial_block = 0
56
56
  self._rotation_lock = threading.Lock()
57
- 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
+
58
63
  self._init_web3()
59
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
+
60
92
  @property
61
93
  def current_rpc(self) -> str:
62
94
  """Get the current active RPC URL."""
@@ -251,6 +283,39 @@ class ChainInterface:
251
283
  ]
252
284
  return any(signal in err_text for signal in quota_signals)
253
285
 
286
+ # -- ChainList enrichment ----------------------------------------------
287
+
288
+ MAX_RPCS = 20 # 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
+ logger.debug(
294
+ f"{self.chain.name}: skipping ChainList enrichment "
295
+ f"(already have {len(self.chain.rpcs)} RPCs)"
296
+ )
297
+ return
298
+
299
+ try:
300
+ from iwa.core.chainlist import ChainlistRPC
301
+
302
+ chainlist = ChainlistRPC()
303
+ extra = chainlist.get_validated_rpcs(
304
+ self.chain.chain_id,
305
+ existing_rpcs=self.chain.rpcs,
306
+ max_results=self.MAX_RPCS - len(self.chain.rpcs),
307
+ )
308
+ if extra:
309
+ self.chain.rpcs.extend(extra)
310
+ logger.info(
311
+ f"Enriched {self.chain.name} with {len(extra)} "
312
+ f"ChainList RPCs (total: {len(self.chain.rpcs)})"
313
+ )
314
+ except Exception as e:
315
+ logger.debug(
316
+ f"ChainList enrichment failed for {self.chain.name}: {e}"
317
+ )
318
+
254
319
  # -- Per-RPC health tracking ------------------------------------------
255
320
 
256
321
  def _mark_rpc_backoff(self, index: int, seconds: float) -> None:
@@ -293,35 +358,45 @@ class ChainInterface:
293
358
 
294
359
  if should_rotate:
295
360
  failed_index = self._current_rpc_index
361
+ failed_rpc = sanitize_rpc_url(self.chain.rpcs[failed_index]) if self.chain.rpcs else "?"
296
362
 
297
363
  # Apply per-RPC backoff so smart rotation skips this RPC.
298
364
  if result["is_quota_exceeded"]:
299
- error_type = "quota exceeded"
300
- self._mark_rpc_backoff(failed_index, self.QUOTA_EXCEEDED_BACKOFF)
365
+ error_type = "QUOTA"
366
+ backoff = self.QUOTA_EXCEEDED_BACKOFF
367
+ self._mark_rpc_backoff(failed_index, backoff)
301
368
  elif result["is_rate_limit"]:
302
- error_type = "rate limit"
303
- self._mark_rpc_backoff(failed_index, self.RATE_LIMIT_BACKOFF)
369
+ error_type = "RATE_LIMIT"
370
+ backoff = self.RATE_LIMIT_BACKOFF
371
+ self._mark_rpc_backoff(failed_index, backoff)
304
372
  # Brief global backoff so other threads don't immediately flood
305
373
  # the same (now backed-off) RPC before rotation takes effect.
306
374
  self._rate_limiter.trigger_backoff(seconds=2.0)
307
375
  else:
308
- error_type = "connection"
309
- self._mark_rpc_backoff(failed_index, self.CONNECTION_ERROR_BACKOFF)
376
+ error_type = "CONNECTION"
377
+ backoff = self.CONNECTION_ERROR_BACKOFF
378
+ self._mark_rpc_backoff(failed_index, backoff)
379
+
380
+ # Count healthy RPCs for visibility
381
+ healthy_count = sum(1 for i in range(len(self.chain.rpcs)) if self._is_rpc_healthy(i))
382
+ total_rpcs = len(self.chain.rpcs) if self.chain.rpcs else 0
310
383
 
311
384
  logger.warning(
312
- f"RPC {error_type} error on {self.chain.name} "
313
- f"(RPC #{failed_index}): {error}"
385
+ f"[{self.chain.name}] RPC #{failed_index} {error_type} → "
386
+ f"backoff {int(backoff)}s ({healthy_count}/{total_rpcs} healthy) | "
387
+ f"{failed_rpc}: {str(error)[:100]}"
314
388
  )
315
389
 
316
390
  if self.rotate_rpc():
317
391
  result["rotated"] = True
318
392
  result["should_retry"] = True
319
- logger.info(f"Rotated to RPC #{self._current_rpc_index} for {self.chain.name}")
393
+ new_rpc = sanitize_rpc_url(self.chain.rpcs[self._current_rpc_index])
394
+ logger.info(f"[{self.chain.name}] Rotated to RPC #{self._current_rpc_index}: {new_rpc}")
320
395
  else:
321
396
  # Rotation skipped (cooldown or single RPC) - still allow retry
322
397
  result["should_retry"] = True
323
- logger.info(
324
- f"RPC rotation skipped, retrying with current RPC #{self._current_rpc_index}"
398
+ logger.debug(
399
+ f"[{self.chain.name}] Rotation skipped (cooldown), retrying RPC #{self._current_rpc_index}"
325
400
  )
326
401
 
327
402
  elif result["is_server_error"]:
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()
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
@@ -39,6 +39,10 @@ class SafeTransactionExecutor:
39
39
  MAX_GAS_MULTIPLIER = 10 # Hard cap: never exceed 10x original estimate
40
40
  DEFAULT_FALLBACK_GAS = 500_000 # Fallback when estimation fails
41
41
 
42
+ # Fee bumping for "max fee per gas less than block base fee" errors
43
+ FEE_BUMP_PERCENTAGE = 1.30 # 30% bump per retry on fee errors
44
+ MAX_FEE_BUMP_FACTOR = 3.0 # Cap: never bump more than 3x original
45
+
42
46
  def __init__(
43
47
  self,
44
48
  chain_interface: "ChainInterface",
@@ -76,6 +80,7 @@ class SafeTransactionExecutor:
76
80
  last_error = None
77
81
  current_gas = safe_tx.safe_tx_gas
78
82
  base_estimate = current_gas if current_gas > 0 else 0
83
+ fee_bump_factor = 1.0 # Multiplier for EIP-1559 fees, increases on fee errors
79
84
 
80
85
  for attempt in range(self.max_retries + 1):
81
86
  SAFE_TX_STATS["total_attempts"] += 1
@@ -89,6 +94,7 @@ class SafeTransactionExecutor:
89
94
  attempt,
90
95
  current_gas,
91
96
  base_estimate,
97
+ fee_bump_factor,
92
98
  )
93
99
 
94
100
  # Check receipt
@@ -106,7 +112,7 @@ class SafeTransactionExecutor:
106
112
  raise ValueError("Transaction reverted on-chain")
107
113
 
108
114
  except Exception as e:
109
- updated_tx, should_retry = self._handle_execution_failure(
115
+ updated_tx, should_retry, is_fee_error = self._handle_execution_failure(
110
116
  e, safe_address, safe_tx, attempt, operation_name
111
117
  )
112
118
  last_error = e
@@ -115,7 +121,12 @@ class SafeTransactionExecutor:
115
121
 
116
122
  # Update gas/nonce for next loop if needed
117
123
  safe_tx = updated_tx
118
- # If gas error, gas is recalculated in next _execute_attempt via fresh estimation
124
+
125
+ # Bump fee multiplier on fee-related errors (base fee > max fee)
126
+ if is_fee_error and fee_bump_factor < self.MAX_FEE_BUMP_FACTOR:
127
+ fee_bump_factor *= self.FEE_BUMP_PERCENTAGE
128
+ fee_bump_factor = min(fee_bump_factor, self.MAX_FEE_BUMP_FACTOR)
129
+ logger.info(f"[{operation_name}] Fee bump factor increased to {fee_bump_factor:.2f}x")
119
130
 
120
131
  delay = self.DEFAULT_RETRY_DELAY * (2**attempt)
121
132
  time.sleep(delay)
@@ -131,6 +142,7 @@ class SafeTransactionExecutor:
131
142
  attempt,
132
143
  current_gas,
133
144
  base_estimate,
145
+ fee_bump_factor: float = 1.0,
134
146
  ) -> str:
135
147
  """Prepare client, estimate gas, simulate, and execute."""
136
148
  # 1. (Re)Create Safe client
@@ -171,24 +183,11 @@ class SafeTransactionExecutor:
171
183
  signatures_backup = safe_tx.signatures
172
184
 
173
185
  try:
174
- # Always pass the first signer key as the executor (gas payer).
175
- # Note: This method does NOT re-sign the Safe hash if signatures are already present.
176
- # Use EIP-1559 'FAST' to ensure adequate priority fee (fixes Gnosis FeeTooLow)
177
- result = safe_tx.execute(signer_keys[0], eip1559_speed=TxSpeed.FAST)
178
-
179
- # Handle both tuple return (tx_hash, tx) and bytes return
180
- if isinstance(result, tuple):
181
- tx_hash_bytes = result[0]
182
- else:
183
- tx_hash_bytes = result
184
-
185
- # Handle both bytes and hex string returns
186
- if isinstance(tx_hash_bytes, bytes):
187
- return f"0x{tx_hash_bytes.hex()}"
188
- elif isinstance(tx_hash_bytes, str):
189
- return tx_hash_bytes if tx_hash_bytes.startswith("0x") else f"0x{tx_hash_bytes}"
190
- else:
191
- return str(tx_hash_bytes)
186
+ # Execute with appropriate gas pricing
187
+ result = self._execute_with_gas_pricing(
188
+ safe_tx, signer_keys[0], fee_bump_factor, operation_name
189
+ )
190
+ return self._extract_tx_hash(result)
192
191
 
193
192
  finally:
194
193
  # Restore signatures for next attempt if needed
@@ -196,6 +195,39 @@ class SafeTransactionExecutor:
196
195
  if safe_tx.signatures != signatures_backup:
197
196
  safe_tx.signatures = signatures_backup
198
197
 
198
+ def _execute_with_gas_pricing(
199
+ self, safe_tx: SafeTx, signer_key: str, fee_bump_factor: float, operation_name: str
200
+ ):
201
+ """Execute transaction with appropriate gas pricing strategy.
202
+
203
+ If fee_bump_factor > 1.0, calculates a bumped gas price to overcome
204
+ base fee volatility. Otherwise uses EIP-1559 FAST speed.
205
+ """
206
+ if fee_bump_factor > 1.0:
207
+ bumped_gas_price = self._calculate_bumped_gas_price(fee_bump_factor)
208
+ if bumped_gas_price:
209
+ logger.debug(
210
+ f"[{operation_name}] Using bumped gas price: {bumped_gas_price} wei "
211
+ f"(factor: {fee_bump_factor:.2f}x)"
212
+ )
213
+ return safe_tx.execute(signer_key, tx_gas_price=bumped_gas_price)
214
+ # Fallback to FAST if calculation fails
215
+ return safe_tx.execute(signer_key, eip1559_speed=TxSpeed.FAST)
216
+ # Default: use EIP-1559 'FAST' speed
217
+ return safe_tx.execute(signer_key, eip1559_speed=TxSpeed.FAST)
218
+
219
+ def _extract_tx_hash(self, result) -> str:
220
+ """Extract transaction hash from execute() result."""
221
+ # Handle both tuple return (tx_hash, tx) and bytes return
222
+ tx_hash_bytes = result[0] if isinstance(result, tuple) else result
223
+
224
+ # Handle both bytes and hex string returns
225
+ if isinstance(tx_hash_bytes, bytes):
226
+ return f"0x{tx_hash_bytes.hex()}"
227
+ if isinstance(tx_hash_bytes, str):
228
+ return tx_hash_bytes if tx_hash_bytes.startswith("0x") else f"0x{tx_hash_bytes}"
229
+ return str(tx_hash_bytes)
230
+
199
231
  def _check_receipt_status(self, receipt) -> bool:
200
232
  """Check if receipt has successful status."""
201
233
  status = getattr(receipt, "status", None)
@@ -210,14 +242,20 @@ class SafeTransactionExecutor:
210
242
  safe_tx: SafeTx,
211
243
  attempt: int,
212
244
  operation_name: str,
213
- ) -> Tuple[SafeTx, bool]:
214
- """Handle execution failure and determine next steps."""
245
+ ) -> Tuple[SafeTx, bool, bool]:
246
+ """Handle execution failure and determine next steps.
247
+
248
+ Returns:
249
+ Tuple of (updated_safe_tx, should_retry, is_fee_error)
250
+
251
+ """
215
252
  classification = self._classify_error(error)
253
+ is_fee_error = classification["is_fee_error"]
216
254
 
217
255
  if attempt >= self.max_retries:
218
256
  SAFE_TX_STATS["final_failures"] += 1
219
257
  logger.error(f"[{operation_name}] Failed after {attempt + 1} attempts: {error}")
220
- return safe_tx, False
258
+ return safe_tx, False, is_fee_error
221
259
 
222
260
  strategy = "retry"
223
261
  safe = self._recreate_safe_client(safe_address)
@@ -231,13 +269,16 @@ class SafeTransactionExecutor:
231
269
  SAFE_TX_STATS["rpc_rotations"] += 1
232
270
  result = self.chain_interface._handle_rpc_error(error)
233
271
  if not result["should_retry"]:
234
- return safe_tx, False
272
+ return safe_tx, False, is_fee_error
273
+ elif is_fee_error:
274
+ strategy = "fee bump"
275
+ SAFE_TX_STATS["gas_retries"] += 1
235
276
  elif classification["is_gas_error"]:
236
277
  strategy = "gas increase"
237
- # Gas increase handled in next attempt loop
278
+ SAFE_TX_STATS["gas_retries"] += 1
238
279
 
239
280
  self._log_retry(attempt + 1, error, strategy)
240
- return safe_tx, True
281
+ return safe_tx, True, is_fee_error
241
282
 
242
283
  def _estimate_safe_tx_gas(self, safe: Safe, safe_tx: SafeTx, base_estimate: int = 0) -> int:
243
284
  """Estimate gas for a Safe transaction with buffer and hard cap."""
@@ -320,14 +361,57 @@ class SafeTransactionExecutor:
320
361
  error
321
362
  ) or self.chain_interface._is_connection_error(error)
322
363
 
364
+ # Fee-specific errors: base fee jumped above our max fee
365
+ fee_error_signals = [
366
+ "max fee per gas less than block base fee",
367
+ "maxfeepergas",
368
+ "fee too low",
369
+ "underpriced",
370
+ ]
371
+ is_fee_error = any(signal in err_text for signal in fee_error_signals)
372
+
323
373
  return {
324
374
  "is_gas_error": any(x in err_text for x in ["gas", "out of gas", "intrinsic"]),
375
+ "is_fee_error": is_fee_error,
325
376
  "is_nonce_error": self._is_nonce_error(error),
326
377
  "is_rpc_error": is_rpc,
327
378
  "is_revert": "revert" in err_text or "execution reverted" in err_text,
328
379
  "is_signature_error": self._is_signature_error(error),
329
380
  }
330
381
 
382
+ def _calculate_bumped_gas_price(self, bump_factor: float) -> Optional[int]:
383
+ """Calculate a bumped gas price based on current base fee.
384
+
385
+ Uses legacy gas price (not EIP-1559) for compatibility with safe-eth-py's
386
+ tx_gas_price parameter. The bumped price ensures we're above the current
387
+ base fee even if it's volatile.
388
+
389
+ Args:
390
+ bump_factor: Multiplier to apply to the base fee (e.g., 1.3 = 30% bump)
391
+
392
+ Returns:
393
+ Gas price in wei, or None if calculation fails
394
+
395
+ """
396
+ try:
397
+ web3 = self.chain_interface.web3
398
+ latest_block = web3.eth.get_block("latest")
399
+ base_fee = latest_block.get("baseFeePerGas")
400
+
401
+ if base_fee is not None:
402
+ # EIP-1559 chain: calculate bumped max fee
403
+ # base_fee * bump_factor * 1.5 (extra buffer) + priority fee
404
+ priority_fee = max(int(web3.eth.max_priority_fee), 1)
405
+ bumped_fee = int(base_fee * bump_factor * 1.5) + priority_fee
406
+ return bumped_fee
407
+ else:
408
+ # Legacy chain: bump the gas price directly
409
+ gas_price = web3.eth.gas_price
410
+ return int(gas_price * bump_factor)
411
+ except Exception as e:
412
+ logger.debug(f"Failed to calculate bumped gas price: {e}")
413
+ return None
414
+
331
415
  def _decode_revert_reason(self, error: Exception) -> Optional[str]:
332
416
  """Attempt to decode the revert reason."""
333
417
  import re
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.60
3
+ Version: 0.0.62
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,8 +23,8 @@ 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=ww779Wek8qeIxu5t0v3hcmwXq7dMaxp0TjpW4Eikg8Y,25924
27
- iwa/core/chain/manager.py,sha256=cFEzh6pK5OyVhjhpeMAqhc9RnRDQR1DjIGiGKp-FXBI,1159
26
+ iwa/core/chain/interface.py,sha256=lW8jmxQNVrUeoBUuYUx9X5kwUhr-Xw3MHS1RsGQeaRQ,29064
27
+ iwa/core/chain/manager.py,sha256=XHwn7ciapFCZVk0rPSJopUqM5Wu3Kpp6XrenkgTE1HA,1397
28
28
  iwa/core/chain/models.py,sha256=WUhAighMKcFdbAUkPU_3dkGbWyAUpRJqXMHLcWFC1xg,5261
29
29
  iwa/core/chain/rate_limiter.py,sha256=Ps1MrR4HHtylxgUAawe6DoC9tuqKagjQdKulqcJD2gs,9093
30
30
  iwa/core/contracts/__init__.py,sha256=P5GFY_pnuI02teqVY2U0t98bn1_SSPAbcAzRMpCdTi4,34
@@ -41,7 +41,7 @@ iwa/core/services/account.py,sha256=0l14qD8_-ZbN_hQUNa7bRZt0tkceHPPc4GHmB8UKqy4,
41
41
  iwa/core/services/balance.py,sha256=MSCEzPRDPlHIjaWD1A2X2oIuiMz5MFJjD7sSHUxQ8OM,3324
42
42
  iwa/core/services/plugin.py,sha256=GNNlbtELyHl7MNVChrypF76GYphxXduxDog4kx1MLi8,3277
43
43
  iwa/core/services/safe.py,sha256=vqvpk7aIqHljaG1zYYpmKdW4mi5OVuoyXcpReISPYM0,15744
44
- iwa/core/services/safe_executor.py,sha256=e7M4Z0w00W5H88I1Yf2qQGP_orI5FDDVSzgilhJaeQo,13509
44
+ iwa/core/services/safe_executor.py,sha256=TqpDgtvh8d5cedYAKBj7s1SW7EnomTT9MW_GnYWMxDE,17157
45
45
  iwa/core/services/transaction.py,sha256=FrGRWn1xo5rbGIr2ToZ2kPzapr3zmWW38oycyB87TK8,19971
46
46
  iwa/core/services/transfer/__init__.py,sha256=ZJfshFxJRsp8rkOqfVvd1cqEzIJ9tqBJh8pc0l90GLk,5576
47
47
  iwa/core/services/transfer/base.py,sha256=sohz-Ss2i-pGYGl4x9bD93cnYKcSvsXaXyvyRawvgQs,9043
@@ -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.60.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
169
+ iwa-0.0.62.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
@@ -178,6 +178,7 @@ tests/test_balance_service.py,sha256=wcuCOVszxPy8nPkldAVcEiygcOK3BuQt797fqAJvbp4
178
178
  tests/test_chain.py,sha256=VZoidSojWyt1y4mQdZdoZsjuuDZjLC6neTC-2SF_Q7I,13957
179
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=2iD5121SxSDpvQTkaoBXOW-cV-FD1NwkRvzlqRn2Ynk,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
@@ -202,7 +203,7 @@ tests/test_rpc_rate_limit.py,sha256=3P_Nd9voFmz-4r_Et-vw8W-Esbq5elSYmRBSOtJGx1Y,
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=xyiNmPo0Ux3jIcdz5AT98YSmha7Pn3HUDoBtBF6AYRE,25032
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.60.dist-info/METADATA,sha256=BpkWW6DmPQhaX7fFo7LtVfNYg3thQWSMiMmR_yGApCc,7337
226
- iwa-0.0.60.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
227
- iwa-0.0.60.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
228
- iwa-0.0.60.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
229
- iwa-0.0.60.dist-info/RECORD,,
226
+ iwa-0.0.62.dist-info/METADATA,sha256=jnpILBgHX2FmSfLn2CXjYKhmkMp_Yr4rlOV5Ep_uknQ,7337
227
+ iwa-0.0.62.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
228
+ iwa-0.0.62.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
229
+ iwa-0.0.62.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
230
+ iwa-0.0.62.dist-info/RECORD,,