iwa 0.0.33__py3-none-any.whl → 0.0.59__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.
Files changed (83) hide show
  1. iwa/core/chain/interface.py +130 -11
  2. iwa/core/chain/models.py +15 -3
  3. iwa/core/chain/rate_limiter.py +48 -12
  4. iwa/core/chainlist.py +15 -10
  5. iwa/core/cli.py +4 -1
  6. iwa/core/contracts/cache.py +1 -1
  7. iwa/core/contracts/contract.py +1 -0
  8. iwa/core/contracts/decoder.py +10 -4
  9. iwa/core/http.py +31 -0
  10. iwa/core/ipfs.py +21 -7
  11. iwa/core/keys.py +65 -15
  12. iwa/core/models.py +58 -13
  13. iwa/core/pricing.py +10 -6
  14. iwa/core/rpc_monitor.py +1 -0
  15. iwa/core/secrets.py +27 -0
  16. iwa/core/services/account.py +1 -1
  17. iwa/core/services/balance.py +0 -23
  18. iwa/core/services/safe.py +72 -45
  19. iwa/core/services/safe_executor.py +350 -0
  20. iwa/core/services/transaction.py +43 -13
  21. iwa/core/services/transfer/erc20.py +14 -3
  22. iwa/core/services/transfer/native.py +14 -31
  23. iwa/core/services/transfer/swap.py +1 -0
  24. iwa/core/tests/test_gnosis_fee.py +91 -0
  25. iwa/core/tests/test_ipfs.py +85 -0
  26. iwa/core/tests/test_pricing.py +65 -0
  27. iwa/core/tests/test_regression_fixes.py +97 -0
  28. iwa/core/utils.py +2 -0
  29. iwa/core/wallet.py +6 -4
  30. iwa/plugins/gnosis/cow/quotes.py +2 -2
  31. iwa/plugins/gnosis/cow/swap.py +18 -32
  32. iwa/plugins/gnosis/tests/test_cow.py +19 -10
  33. iwa/plugins/olas/constants.py +15 -5
  34. iwa/plugins/olas/contracts/activity_checker.py +3 -3
  35. iwa/plugins/olas/contracts/staking.py +0 -1
  36. iwa/plugins/olas/events.py +15 -13
  37. iwa/plugins/olas/importer.py +29 -25
  38. iwa/plugins/olas/models.py +0 -3
  39. iwa/plugins/olas/plugin.py +16 -14
  40. iwa/plugins/olas/service_manager/drain.py +16 -9
  41. iwa/plugins/olas/service_manager/lifecycle.py +23 -12
  42. iwa/plugins/olas/service_manager/staking.py +15 -10
  43. iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
  44. iwa/plugins/olas/tests/test_olas_archiving.py +83 -0
  45. iwa/plugins/olas/tests/test_olas_integration.py +49 -29
  46. iwa/plugins/olas/tests/test_olas_view.py +5 -1
  47. iwa/plugins/olas/tests/test_service_manager.py +15 -17
  48. iwa/plugins/olas/tests/test_service_manager_errors.py +6 -5
  49. iwa/plugins/olas/tests/test_service_manager_flows.py +7 -6
  50. iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
  51. iwa/plugins/olas/tests/test_service_staking.py +64 -38
  52. iwa/tools/drain_accounts.py +61 -0
  53. iwa/tools/list_contracts.py +2 -0
  54. iwa/tools/reset_env.py +2 -1
  55. iwa/tools/test_chainlist.py +5 -1
  56. iwa/tui/screens/wallets.py +2 -4
  57. iwa/web/routers/accounts.py +1 -1
  58. iwa/web/routers/olas/services.py +10 -5
  59. iwa/web/static/app.js +21 -9
  60. iwa/web/static/style.css +4 -0
  61. iwa/web/tests/test_web_endpoints.py +2 -2
  62. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/METADATA +6 -3
  63. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/RECORD +82 -71
  64. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/WHEEL +1 -1
  65. tests/test_balance_service.py +0 -43
  66. tests/test_chain.py +13 -5
  67. tests/test_cli.py +2 -2
  68. tests/test_drain_coverage.py +12 -6
  69. tests/test_keys.py +23 -23
  70. tests/test_rate_limiter.py +2 -2
  71. tests/test_rate_limiter_retry.py +103 -0
  72. tests/test_rpc_efficiency.py +4 -1
  73. tests/test_rpc_rate_limit.py +34 -0
  74. tests/test_rpc_rotation.py +59 -11
  75. tests/test_safe_coverage.py +37 -23
  76. tests/test_safe_executor.py +361 -0
  77. tests/test_safe_integration.py +153 -0
  78. tests/test_safe_service.py +1 -1
  79. tests/test_transfer_swap_unit.py +5 -1
  80. tests/test_pricing.py +0 -160
  81. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/entry_points.txt +0 -0
  82. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/licenses/LICENSE +0 -0
  83. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/top_level.txt +0 -0
@@ -4,6 +4,7 @@ import threading
4
4
  import time
5
5
  from typing import Callable, Dict, Optional, TypeVar, Union
6
6
 
7
+ import requests
7
8
  from web3 import Web3
8
9
 
9
10
  from iwa.core.chain.errors import TenderlyQuotaExceededError, sanitize_rpc_url
@@ -23,6 +24,7 @@ class ChainInterface:
23
24
 
24
25
  DEFAULT_MAX_RETRIES = 6 # Allow trying most/all available RPCs on rate limit
25
26
  DEFAULT_RETRY_DELAY = 1.0 # Base delay between retries (exponential backoff)
27
+ ROTATION_COOLDOWN_SECONDS = 2.0 # Minimum time between RPC rotations
26
28
 
27
29
  chain: SupportedChain
28
30
 
@@ -34,9 +36,11 @@ class ChainInterface:
34
36
  chain: SupportedChain = getattr(SupportedChains(), chain.lower())
35
37
 
36
38
  self.chain = chain
37
- self._rate_limiter = get_rate_limiter(chain.name)
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)
38
41
  self._current_rpc_index = 0
39
42
  self._rpc_failure_counts: Dict[int, int] = {}
43
+ self._last_rotation_time = 0.0 # Monotonic timestamp of last rotation
40
44
 
41
45
  if self.chain.rpc and self.chain.rpc.startswith("http://"):
42
46
  logger.warning(
@@ -46,6 +50,7 @@ class ChainInterface:
46
50
 
47
51
  self._initial_block = 0
48
52
  self._rotation_lock = threading.Lock()
53
+ self._session = requests.Session()
49
54
  self._init_web3()
50
55
 
51
56
  @property
@@ -213,12 +218,24 @@ class ChainInterface:
213
218
  ]
214
219
  return any(signal in err_text for signal in server_error_signals)
215
220
 
221
+ def _is_gas_error(self, error: Exception) -> bool:
222
+ """Check if error is related to gas limits or fees."""
223
+ err_text = str(error).lower()
224
+ gas_signals = [
225
+ "intrinsic gas too low",
226
+ "feetoolow",
227
+ "gas limit",
228
+ "underpriced",
229
+ ]
230
+ return any(signal in err_text for signal in gas_signals)
231
+
216
232
  def _handle_rpc_error(self, error: Exception) -> Dict[str, Union[bool, int]]:
217
233
  """Handle RPC errors with smart rotation and retry logic."""
218
234
  result: Dict[str, Union[bool, int]] = {
219
235
  "is_rate_limit": self._is_rate_limit_error(error),
220
236
  "is_connection_error": self._is_connection_error(error),
221
237
  "is_server_error": self._is_server_error(error),
238
+ "is_gas_error": self._is_gas_error(error),
222
239
  "is_tenderly_quota": self._is_tenderly_quota_exceeded(error),
223
240
  "rotated": False,
224
241
  "should_retry": False,
@@ -242,9 +259,11 @@ class ChainInterface:
242
259
 
243
260
  if should_rotate:
244
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)
245
264
  logger.warning(
246
265
  f"RPC {error_type} error on {self.chain.name} "
247
- f"(RPC #{self._current_rpc_index}): {error}"
266
+ f"(current RPC #{self._current_rpc_index}): {error_msg}"
248
267
  )
249
268
 
250
269
  if self.rotate_rpc():
@@ -253,14 +272,22 @@ class ChainInterface:
253
272
  logger.info(f"Rotated to RPC #{self._current_rpc_index} for {self.chain.name}")
254
273
  else:
255
274
  if result["is_rate_limit"]:
256
- self._rate_limiter.trigger_backoff(seconds=5.0)
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).
257
278
  result["should_retry"] = True
258
- logger.warning("No other RPCs available, triggered backoff")
279
+ logger.info(
280
+ f"RPC rotation skipped, retrying with current RPC #{self._current_rpc_index}"
281
+ )
259
282
 
260
283
  elif result["is_server_error"]:
261
284
  logger.warning(f"Server error on {self.chain.name}: {error}")
262
285
  result["should_retry"] = True
263
286
 
287
+ elif result["is_gas_error"]:
288
+ logger.warning(f"Gas/Fee error detected: {error}. Allowing retry for adjustment.")
289
+ result["should_retry"] = True
290
+
264
291
  return result
265
292
 
266
293
  def rotate_rpc(self) -> bool:
@@ -269,11 +296,22 @@ class ChainInterface:
269
296
  if not self.chain.rpcs or len(self.chain.rpcs) <= 1:
270
297
  return False
271
298
 
299
+ # Cooldown: prevent cascade rotations from in-flight requests
300
+ 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
+ )
307
+ return False
308
+
272
309
  # Simple Round Robin rotation
273
310
  self._current_rpc_index = (self._current_rpc_index + 1) % len(self.chain.rpcs)
274
311
  # Internal call to _init_web3 already expects to be under lock if called from here,
275
312
  # but _init_web3 itself doesn't have a lock. Let's make it consistent.
276
313
  self._init_web3_under_lock()
314
+ self._last_rotation_time = now
277
315
 
278
316
  logger.info(
279
317
  f"Rotated RPC for {self.chain.name} to index {self._current_rpc_index}: {self.chain.rpcs[self._current_rpc_index]}"
@@ -288,7 +326,11 @@ class ChainInterface:
288
326
  def _init_web3_under_lock(self):
289
327
  """Internal non-thread-safe web3 initialization."""
290
328
  rpc_url = self.chain.rpcs[self._current_rpc_index] if self.chain.rpcs else ""
291
- raw_web3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": DEFAULT_RPC_TIMEOUT}))
329
+ raw_web3 = Web3(
330
+ Web3.HTTPProvider(
331
+ rpc_url, request_kwargs={"timeout": DEFAULT_RPC_TIMEOUT}, session=self._session
332
+ )
333
+ )
292
334
 
293
335
  # Use duck typing to check if current web3 is a RateLimitedWeb3 wrapper
294
336
  if hasattr(self, "web3") and hasattr(self.web3, "set_backend"):
@@ -371,7 +413,9 @@ class ChainInterface:
371
413
  except Exception:
372
414
  return address[:6] + "..." + address[-4:]
373
415
 
374
- def get_token_decimals(self, address: EthereumAddress, fallback_to_18: bool = True) -> Optional[int]:
416
+ def get_token_decimals(
417
+ self, address: EthereumAddress, fallback_to_18: bool = True
418
+ ) -> Optional[int]:
375
419
  """Get token decimals for an address.
376
420
 
377
421
  Args:
@@ -388,7 +432,15 @@ class ChainInterface:
388
432
  # Use _web3 directly to ensure current provider after RPC rotation
389
433
  contract = self.web3._web3.eth.contract(
390
434
  address=self.web3.to_checksum_address(address),
391
- abi=[{"constant": True, "inputs": [], "name": "decimals", "outputs": [{"type": "uint8"}], "type": "function"}]
435
+ abi=[
436
+ {
437
+ "constant": True,
438
+ "inputs": [],
439
+ "name": "decimals",
440
+ "outputs": [{"type": "uint8"}],
441
+ "type": "function",
442
+ }
443
+ ],
392
444
  )
393
445
  return contract.functions.decimals().call()
394
446
  except Exception:
@@ -423,18 +475,85 @@ class ChainInterface:
423
475
  return 500_000
424
476
 
425
477
  def calculate_transaction_params(
426
- self, built_method: Callable, tx_params: Dict[str, Union[str, int]]
478
+ self, built_method: Optional[Callable], tx_params: Dict[str, Union[str, int]]
427
479
  ) -> Dict[str, Union[str, int]]:
428
- """Calculate transaction parameters for a contract function call."""
480
+ """Calculate transaction parameters for a contract function call or native transfer."""
481
+ # Baseline parameters
429
482
  params = {
430
483
  "from": tx_params["from"],
431
484
  "value": tx_params.get("value", 0),
432
485
  "nonce": self.web3.eth.get_transaction_count(tx_params["from"]),
433
- "gas": self.estimate_gas(built_method, tx_params),
434
- "gasPrice": self.web3.eth.gas_price,
435
486
  }
487
+
488
+ # Add 'to' only for native transfers (built_method is None)
489
+ # Contract calls already have the target address in the contract object
490
+ if not built_method and "to" in tx_params:
491
+ params["to"] = tx_params["to"]
492
+ elif (
493
+ not built_method and "to" in params
494
+ ): # Fallback if added to params earlier (though not here yet)
495
+ pass
496
+
497
+ # Determine gas
498
+ if built_method:
499
+ # Contract function call
500
+ params["gas"] = self.estimate_gas(built_method, tx_params)
501
+ elif "gas" in tx_params:
502
+ # Manual gas override
503
+ params["gas"] = tx_params["gas"]
504
+ else:
505
+ # Native transfer - dynamic estimation
506
+ try:
507
+ # web3.eth.estimate_gas returns gas for the dict it receives
508
+ est_params = {"from": params["from"], "to": params["to"], "value": params["value"]}
509
+ # Remove None 'to' for contract creation simulation if needed, but usually send() has to
510
+ if not est_params["to"]:
511
+ est_params.pop("to")
512
+
513
+ estimated = self.web3.eth.estimate_gas(est_params)
514
+ # Apply 10% buffer for safety
515
+ params["gas"] = int(estimated * 1.1)
516
+ logger.debug(
517
+ f"[GAS] Estimated native transfer gas: {params['gas']} (raw: {estimated})"
518
+ )
519
+ except Exception as e:
520
+ logger.debug(f"[GAS] Native estimation failed, fallback to 21000: {e}")
521
+ params["gas"] = 21_000
522
+
523
+ # Add EIP-1559 or Legacy fees
524
+ params.update(self.get_suggested_fees())
436
525
  return params
437
526
 
527
+ def get_suggested_fees(self) -> Dict[str, int]:
528
+ """Calculate suggested fees for a transaction (EIP-1559 or legacy)."""
529
+ try:
530
+ # Check for EIP-1559 support
531
+ latest_block = self.web3.eth.get_block("latest")
532
+ base_fee = latest_block.get("baseFeePerGas")
533
+
534
+ if base_fee is not None:
535
+ # EIP-1559 logic
536
+ max_priority_fee = int(self.web3.eth.max_priority_fee)
537
+
538
+ # Gnosis specific: ensure min priority fee (critical for validation)
539
+ if self.chain.name.lower() == "gnosis":
540
+ if max_priority_fee < 1:
541
+ max_priority_fee = 1 # Network minimum is 1 wei
542
+
543
+ # Global minimum for EIP-1559
544
+ if max_priority_fee < 1:
545
+ max_priority_fee = 1
546
+
547
+ # Buffer max_fee to handle base fee expansion
548
+ max_fee = int(base_fee * 1.5) + max_priority_fee
549
+
550
+ return {"maxFeePerGas": max_fee, "maxPriorityFeePerGas": max_priority_fee}
551
+ except Exception as e:
552
+ logger.debug(f"Failed to calculate EIP-1559 fees: {e}, falling back to legacy")
553
+
554
+ # Legacy fallback
555
+ return {"gasPrice": self.web3.eth.gas_price}
556
+
438
557
  def wait_for_no_pending_tx(
439
558
  self, from_address: EthereumAddress, max_wait_seconds: int = 60, poll_interval: float = 2.0
440
559
  ):
iwa/core/chain/models.py CHANGED
@@ -26,6 +26,9 @@ class SupportedChain(BaseModel):
26
26
 
27
27
  def get_token_address(self, token_address_or_name: str) -> Optional[EthereumAddress]:
28
28
  """Get token address"""
29
+ if not token_address_or_name:
30
+ return None
31
+
29
32
  try:
30
33
  address = EthereumAddress(token_address_or_name)
31
34
  except Exception:
@@ -35,9 +38,18 @@ class SupportedChain(BaseModel):
35
38
  return address
36
39
 
37
40
  if address is None:
38
- return self.tokens.get(token_address_or_name, None)
39
-
40
- return None
41
+ # Try direct lookup
42
+ token_addr = self.tokens.get(token_address_or_name, None)
43
+ if token_addr:
44
+ return token_addr
45
+
46
+ # Try case-insensitive lookup
47
+ target_lower = token_address_or_name.lower()
48
+ for name, addr in self.tokens.items():
49
+ if name.lower() == target_lower:
50
+ return addr
51
+
52
+ return None
41
53
 
42
54
  def get_token_name(self, token_address: str) -> Optional[str]:
43
55
  """Get token name from address."""
@@ -108,12 +108,11 @@ def get_rate_limiter(chain_name: str, rate: float = None, burst: int = None) ->
108
108
  class RateLimitedEth:
109
109
  """Wrapper around web3.eth that applies rate limiting transparently."""
110
110
 
111
- RPC_METHODS = {
111
+ READ_METHODS = {
112
112
  "get_balance",
113
113
  "get_code",
114
114
  "get_transaction_count",
115
115
  "estimate_gas",
116
- "send_raw_transaction",
117
116
  "wait_for_transaction_receipt",
118
117
  "get_block",
119
118
  "get_transaction",
@@ -122,6 +121,16 @@ class RateLimitedEth:
122
121
  "get_logs",
123
122
  }
124
123
 
124
+ WRITE_METHODS = {
125
+ "send_raw_transaction",
126
+ }
127
+
128
+ # Helper sets for efficient lookup
129
+ RPC_METHODS = READ_METHODS | WRITE_METHODS
130
+
131
+ DEFAULT_READ_RETRIES = 3
132
+ DEFAULT_READ_RETRY_DELAY = 0.5
133
+
125
134
  def __init__(self, web3_eth, rate_limiter: RPCRateLimiter, chain_interface: "ChainInterface"):
126
135
  """Initialize RateLimitedEth wrapper."""
127
136
  object.__setattr__(self, "_eth", web3_eth)
@@ -133,7 +142,7 @@ class RateLimitedEth:
133
142
  attr = getattr(self._eth, name)
134
143
 
135
144
  if name in self.RPC_METHODS and callable(attr):
136
- return self._wrap_with_rate_limit(attr, name)
145
+ return self._wrap_with_retry(attr, name)
137
146
 
138
147
  return attr
139
148
 
@@ -151,23 +160,50 @@ class RateLimitedEth:
151
160
  else:
152
161
  delattr(self._eth, name)
153
162
 
154
- def _wrap_with_rate_limit(self, method, method_name):
155
- """Wrap a method with rate limiting.
163
+ @property
164
+ def block_number(self):
165
+ """Get block number with retry."""
166
+ return self._execute_with_retry(lambda: self._eth.block_number, "block_number")
156
167
 
157
- Note: Error handling (rotation, retry) is NOT done here.
158
- It is the responsibility of `ChainInterface.with_retry()` to handle
159
- errors and rotate RPCs as needed. This wrapper only ensures
160
- rate limiting.
161
- """
168
+ @property
169
+ def gas_price(self):
170
+ """Get gas price with retry."""
171
+ return self._execute_with_retry(lambda: self._eth.gas_price, "gas_price")
172
+
173
+ def _wrap_with_retry(self, method, method_name):
174
+ """Wrap method with rate limiting and retry for reads."""
162
175
 
163
176
  def wrapper(*args, **kwargs):
164
177
  if not self._rate_limiter.acquire(timeout=30.0):
165
- raise TimeoutError(f"Rate limit timeout waiting for {method_name}")
178
+ raise TimeoutError(f"Rate limit timeout for {method_name}")
179
+
180
+ # Writes: no auto-retry (handled by caller or not safe)
181
+ if method_name in self.WRITE_METHODS:
182
+ return method(*args, **kwargs)
166
183
 
167
- return method(*args, **kwargs)
184
+ # Reads: with retry
185
+ return self._execute_with_retry(method, method_name, *args, **kwargs)
168
186
 
169
187
  return wrapper
170
188
 
189
+ def _execute_with_retry(self, method, method_name, *args, **kwargs):
190
+ """Execute read operation with retry logic."""
191
+ for attempt in range(self.DEFAULT_READ_RETRIES + 1):
192
+ try:
193
+ return method(*args, **kwargs)
194
+ except Exception as e:
195
+ # Use chain interface to handle error (logging, rotation, etc.)
196
+ result = self._chain_interface._handle_rpc_error(e)
197
+
198
+ if not result["should_retry"] or attempt >= self.DEFAULT_READ_RETRIES:
199
+ raise
200
+
201
+ delay = self.DEFAULT_READ_RETRY_DELAY * (2**attempt)
202
+ logger.debug(
203
+ f"{method_name} attempt {attempt + 1} failed, retrying in {delay:.1f}s..."
204
+ )
205
+ time.sleep(delay)
206
+
171
207
 
172
208
  class RateLimitedWeb3:
173
209
  """Wrapper around Web3 instance that applies rate limiting transparently."""
iwa/core/chainlist.py CHANGED
@@ -1,4 +1,5 @@
1
1
  """Module for fetching and parsing RPCs from Chainlist.org."""
2
+
2
3
  import json
3
4
  import time
4
5
  from dataclasses import dataclass
@@ -78,7 +79,7 @@ class ChainlistRPC:
78
79
  self.fetch_data()
79
80
 
80
81
  for entry in self._data:
81
- if entry.get('chainId') == chain_id:
82
+ if entry.get("chainId") == chain_id:
82
83
  return entry
83
84
  return None
84
85
 
@@ -88,22 +89,25 @@ class ChainlistRPC:
88
89
  if not chain_data:
89
90
  return []
90
91
 
91
- raw_rpcs = chain_data.get('rpc', [])
92
+ raw_rpcs = chain_data.get("rpc", [])
92
93
  nodes = []
93
94
  for rpc in raw_rpcs:
94
- nodes.append(RPCNode(
95
- url=rpc.get('url', ''),
96
- is_working=True,
97
- privacy=rpc.get('privacy'),
98
- tracking=rpc.get('tracking')
99
- ))
95
+ nodes.append(
96
+ RPCNode(
97
+ url=rpc.get("url", ""),
98
+ is_working=True,
99
+ privacy=rpc.get("privacy"),
100
+ tracking=rpc.get("tracking"),
101
+ )
102
+ )
100
103
  return nodes
101
104
 
102
105
  def get_https_rpcs(self, chain_id: int) -> List[str]:
103
106
  """Returns a list of HTTPS RPC URLs for the given chain."""
104
107
  rpcs = self.get_rpcs(chain_id)
105
108
  return [
106
- node.url for node in rpcs
109
+ node.url
110
+ for node in rpcs
107
111
  if node.url.startswith("https://") or node.url.startswith("http://")
108
112
  ]
109
113
 
@@ -111,6 +115,7 @@ class ChainlistRPC:
111
115
  """Returns a list of WSS RPC URLs for the given chain."""
112
116
  rpcs = self.get_rpcs(chain_id)
113
117
  return [
114
- node.url for node in rpcs
118
+ node.url
119
+ for node in rpcs
115
120
  if node.url.startswith("wss://") or node.url.startswith("ws://")
116
121
  ]
iwa/core/cli.py CHANGED
@@ -16,13 +16,16 @@ from iwa.tui.app import IwaApp
16
16
 
17
17
  iwa_cli = typer.Typer(help="iwa command line interface")
18
18
 
19
+
19
20
  @iwa_cli.callback()
20
21
  def main_callback(ctx: typer.Context):
21
22
  """Initialize IWA CLI."""
22
23
  # Print banner on startup
23
24
  from iwa.core.utils import get_version, print_banner
25
+
24
26
  print_banner("iwa", get_version("iwa"))
25
27
 
28
+
26
29
  wallet_cli = typer.Typer(help="Manage wallet")
27
30
 
28
31
  iwa_cli.add_typer(wallet_cli, name="wallet")
@@ -40,7 +43,7 @@ def account_create(
40
43
  """Create a new wallet account"""
41
44
  key_storage = KeyStorage()
42
45
  try:
43
- key_storage.create_account(tag)
46
+ key_storage.generate_new_account(tag)
44
47
  except ValueError as e:
45
48
  typer.echo(f"Error: {e}")
46
49
  raise typer.Exit(code=1) from e
@@ -62,7 +62,7 @@ class ContractCache:
62
62
 
63
63
  key = self._make_key(contract_cls, address, chain_name)
64
64
  now = time.time()
65
- expiry = (ttl if ttl is not None else self.ttl)
65
+ expiry = ttl if ttl is not None else self.ttl
66
66
 
67
67
  with self._lock:
68
68
  # Check if cached and valid
@@ -31,6 +31,7 @@ def clear_abi_cache() -> None:
31
31
  global _ABI_CACHE
32
32
  _ABI_CACHE = {}
33
33
 
34
+
34
35
  # Panic codes (from Solidity)
35
36
  # ... (rest of PANIC_CODES) ...
36
37
  PANIC_CODES = {
@@ -58,7 +58,7 @@ class ErrorDecoder:
58
58
  # Also check core ABIs if they are in a different place
59
59
  core_abi_path = src_root / "iwa" / "core" / "contracts" / "abis"
60
60
  if core_abi_path.exists() and core_abi_path not in [f.parent for f in abi_files]:
61
- abi_files.extend(list(core_abi_path.glob("*.json")))
61
+ abi_files.extend(list(core_abi_path.glob("*.json")))
62
62
 
63
63
  logger.debug(f"Found {len(abi_files)} ABI files for error decoding.")
64
64
 
@@ -66,7 +66,11 @@ class ErrorDecoder:
66
66
  try:
67
67
  with open(abi_path, "r", encoding="utf-8") as f:
68
68
  content = json.load(f)
69
- abi = content.get("abi") if isinstance(content, dict) and "abi" in content else content
69
+ abi = (
70
+ content.get("abi")
71
+ if isinstance(content, dict) and "abi" in content
72
+ else content
73
+ )
70
74
  if isinstance(abi, list):
71
75
  self._process_abi(abi, abi_path.name)
72
76
  except Exception as e:
@@ -91,7 +95,7 @@ class ErrorDecoder:
91
95
  "types": types,
92
96
  "arg_names": names,
93
97
  "source": source_name,
94
- "signature": signature
98
+ "signature": signature,
95
99
  }
96
100
 
97
101
  if selector not in self._selectors:
@@ -145,7 +149,9 @@ class ErrorDecoder:
145
149
  for d in self._selectors[selector]:
146
150
  try:
147
151
  decoded = decode(d["types"], bytes.fromhex(encoded_args))
148
- args_str = ", ".join(f"{n}={v}" for n, v in zip(d["arg_names"], decoded, strict=False))
152
+ args_str = ", ".join(
153
+ f"{n}={v}" for n, v in zip(d["arg_names"], decoded, strict=False)
154
+ )
149
155
  results.append((d["name"], f"{d['name']}({args_str})", d["source"]))
150
156
  except Exception:
151
157
  # Try next possible decoding for this selector
iwa/core/http.py ADDED
@@ -0,0 +1,31 @@
1
+ """Shared HTTP session utilities."""
2
+
3
+ import requests
4
+ from requests.adapters import HTTPAdapter
5
+ from urllib3.util.retry import Retry
6
+
7
+ DEFAULT_RETRY_TOTAL = 3
8
+ DEFAULT_BACKOFF_FACTOR = 1
9
+ DEFAULT_STATUS_FORCELIST = [429, 500, 502, 503, 504]
10
+
11
+
12
+ def create_retry_session(
13
+ retries: int = DEFAULT_RETRY_TOTAL,
14
+ backoff_factor: int = DEFAULT_BACKOFF_FACTOR,
15
+ status_forcelist: list[int] | None = None,
16
+ ) -> requests.Session:
17
+ """Create a requests.Session with retry strategy.
18
+
19
+ Used by PriceService, IPFS, and other modules that need
20
+ persistent HTTP connections with automatic retry.
21
+ """
22
+ session = requests.Session()
23
+ retry_strategy = Retry(
24
+ total=retries,
25
+ backoff_factor=backoff_factor,
26
+ status_forcelist=status_forcelist or DEFAULT_STATUS_FORCELIST,
27
+ )
28
+ adapter = HTTPAdapter(max_retries=retry_strategy)
29
+ session.mount("https://", adapter)
30
+ session.mount("http://", adapter)
31
+ return session
iwa/core/ipfs.py CHANGED
@@ -7,13 +7,21 @@ direct HTTP API calls, avoiding heavy dependencies like open-aea.
7
7
  import hashlib
8
8
  import json
9
9
  import uuid
10
- from typing import Any, Dict, Optional, Tuple
10
+ from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
11
11
 
12
12
  import aiohttp
13
13
  from multiformats import CID
14
14
 
15
+ from iwa.core.http import create_retry_session
15
16
  from iwa.core.models import Config
16
17
 
18
+ if TYPE_CHECKING:
19
+ import requests
20
+
21
+ # Global persistent sessions (reused across calls to prevent FD leaks)
22
+ _SYNC_SESSION: Optional["requests.Session"] = None
23
+ _ASYNC_SESSION: Optional[aiohttp.ClientSession] = None
24
+
17
25
 
18
26
  def _compute_cid_v1_hex(data: bytes) -> str:
19
27
  """Compute CIDv1 hex representation from raw data.
@@ -56,10 +64,13 @@ async def push_to_ipfs_async(
56
64
  form = aiohttp.FormData()
57
65
  form.add_field("file", data, filename="data", content_type="application/octet-stream")
58
66
 
59
- async with aiohttp.ClientSession() as session:
60
- async with session.post(endpoint, data=form, params=params) as response:
61
- response.raise_for_status()
62
- result = await response.json()
67
+ global _ASYNC_SESSION
68
+ if _ASYNC_SESSION is None or _ASYNC_SESSION.closed:
69
+ _ASYNC_SESSION = aiohttp.ClientSession()
70
+
71
+ async with _ASYNC_SESSION.post(endpoint, data=form, params=params) as response:
72
+ response.raise_for_status()
73
+ result = await response.json()
63
74
 
64
75
  cid_str = result["Hash"]
65
76
  cid = CID.decode(cid_str)
@@ -83,7 +94,10 @@ def push_to_ipfs_sync(
83
94
  :param pin: Whether to pin the content (default True).
84
95
  :return: Tuple of (CIDv1 string, CIDv1 hex representation).
85
96
  """
86
- import requests
97
+ global _SYNC_SESSION
98
+
99
+ if _SYNC_SESSION is None:
100
+ _SYNC_SESSION = create_retry_session()
87
101
 
88
102
  url = api_url or Config().core.ipfs_api_url
89
103
  endpoint = f"{url}/api/v0/add"
@@ -92,7 +106,7 @@ def push_to_ipfs_sync(
92
106
 
93
107
  files = {"file": ("data", data, "application/octet-stream")}
94
108
 
95
- response = requests.post(endpoint, files=files, params=params, timeout=60)
109
+ response = _SYNC_SESSION.post(endpoint, files=files, params=params, timeout=60)
96
110
  response.raise_for_status()
97
111
  result = response.json()
98
112