iwa 0.0.66__py3-none-any.whl → 0.1.0__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.
@@ -228,6 +228,7 @@ class ChainInterface:
228
228
  "connect timeout",
229
229
  "remote end closed",
230
230
  "broken pipe",
231
+ # Note: "too many open files" handled separately by _is_fd_exhaustion_error
231
232
  ]
232
233
  return any(signal in err_text for signal in connection_signals)
233
234
 
@@ -254,6 +255,21 @@ class ChainInterface:
254
255
  ]
255
256
  return any(signal in err_text for signal in server_error_signals)
256
257
 
258
+ def _is_fd_exhaustion_error(self, error: Exception) -> bool:
259
+ """Check if error is due to file descriptor exhaustion (OSError 24).
260
+
261
+ When FDs are exhausted, rotating RPCs makes things WORSE because it
262
+ creates more connections. Instead, we need to pause and let existing
263
+ connections drain before retrying.
264
+ """
265
+ err_text = str(error).lower()
266
+ fd_signals = [
267
+ "too many open files",
268
+ "oserror(24",
269
+ "errno 24",
270
+ ]
271
+ return any(signal in err_text for signal in fd_signals)
272
+
257
273
  def _is_gas_error(self, error: Exception) -> bool:
258
274
  """Check if error is related to gas limits or fees."""
259
275
  err_text = str(error).lower()
@@ -326,6 +342,9 @@ class ChainInterface:
326
342
  """Return True if the RPC at *index* is not in backoff."""
327
343
  return time.monotonic() >= self._rpc_backoff_until.get(index, 0.0)
328
344
 
345
+ # FD exhaustion backoff: wait for connections to drain
346
+ FD_EXHAUSTION_BACKOFF = 60.0 # Long pause to let FDs drain
347
+
329
348
  def _handle_rpc_error(self, error: Exception) -> Dict[str, Union[bool, int]]:
330
349
  """Handle RPC errors with smart rotation and retry logic."""
331
350
  result: Dict[str, Union[bool, int]] = {
@@ -335,10 +354,26 @@ class ChainInterface:
335
354
  "is_gas_error": self._is_gas_error(error),
336
355
  "is_tenderly_quota": self._is_tenderly_quota_exceeded(error),
337
356
  "is_quota_exceeded": self._is_quota_exceeded_error(error),
357
+ "is_fd_exhaustion": self._is_fd_exhaustion_error(error),
338
358
  "rotated": False,
339
359
  "should_retry": False,
340
360
  }
341
361
 
362
+ # FD exhaustion: DO NOT rotate (creates more connections), just pause
363
+ if result["is_fd_exhaustion"]:
364
+ logger.error(
365
+ f"[{self.chain.name}] FD EXHAUSTION detected (too many open files). "
366
+ f"Pausing all RPCs for {int(self.FD_EXHAUSTION_BACKOFF)}s to drain connections. "
367
+ f"NO rotation to avoid creating more connections."
368
+ )
369
+ # Mark ALL RPCs as in backoff to prevent any activity
370
+ for i in range(len(self.chain.rpcs) if self.chain.rpcs else 0):
371
+ self._mark_rpc_backoff(i, self.FD_EXHAUSTION_BACKOFF)
372
+ # Trigger global rate limit backoff
373
+ self._rate_limiter.trigger_backoff(seconds=self.FD_EXHAUSTION_BACKOFF)
374
+ result["should_retry"] = True # Retry after backoff
375
+ return result
376
+
342
377
  if result["is_tenderly_quota"]:
343
378
  logger.error(
344
379
  "TENDERLY QUOTA EXCEEDED! The virtual network has reached its limit. "
iwa/core/services/safe.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Safe service module."""
2
2
 
3
- from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
3
+ from typing import TYPE_CHECKING, List, Optional, Tuple
4
4
 
5
5
  from loguru import logger
6
6
  from safe_eth.eth import EthereumClient
@@ -35,7 +35,6 @@ class SafeService:
35
35
  """Initialize SafeService."""
36
36
  self.key_storage = key_storage
37
37
  self.account_service = account_service
38
- self._client_cache: Dict[str, EthereumClient] = {}
39
38
 
40
39
  def create_safe(
41
40
  self,
@@ -100,15 +99,14 @@ class SafeService:
100
99
 
101
100
  def _get_ethereum_client(self, chain_name: str) -> EthereumClient:
102
101
  from iwa.core.chain import ChainInterfaces
102
+ from iwa.plugins.gnosis.safe import get_ethereum_client
103
103
 
104
104
  # Use ChainInterface which has proper RPC rotation and parsing
105
105
  chain_interface = ChainInterfaces().get(chain_name)
106
106
  rpc_url = chain_interface.current_rpc
107
107
 
108
- if rpc_url not in self._client_cache:
109
- self._client_cache[rpc_url] = EthereumClient(rpc_url)
110
-
111
- return self._client_cache[rpc_url]
108
+ # Use shared cache to prevent FD exhaustion
109
+ return get_ethereum_client(rpc_url)
112
110
 
113
111
  def _deploy_safe_contract(
114
112
  self,
@@ -256,11 +254,8 @@ class SafeService:
256
254
  continue
257
255
 
258
256
  for chain in account.chains:
259
- from iwa.core.chain import ChainInterfaces
260
-
261
- # Use ChainInterface which has proper RPC rotation and parsing
262
- chain_interface = ChainInterfaces().get(chain)
263
- ethereum_client = EthereumClient(chain_interface.current_rpc)
257
+ # Reuse cached EthereumClient to prevent FD exhaustion
258
+ ethereum_client = self._get_ethereum_client(chain)
264
259
 
265
260
  code = ethereum_client.w3.eth.get_code(account.address)
266
261
 
@@ -375,7 +370,9 @@ class SafeService:
375
370
  if not safe_account or not isinstance(safe_account, StoredSafeAccount):
376
371
  raise ValueError(f"Safe account '{safe_address_or_tag}' not found.")
377
372
 
378
- safe = SafeMultisig(safe_account, chain_name)
373
+ # Reuse cached EthereumClient to prevent FD exhaustion
374
+ ethereum_client = self._get_ethereum_client(chain_name)
375
+ safe = SafeMultisig(safe_account, chain_name, ethereum_client=ethereum_client)
379
376
  safe_tx = safe.build_tx(
380
377
  to=to,
381
378
  value=value,
@@ -1,10 +1,10 @@
1
1
  """Safe transaction executor with retry logic and gas handling."""
2
2
 
3
3
  import time
4
- from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
4
+ from typing import TYPE_CHECKING, List, Optional, Tuple
5
5
 
6
6
  from loguru import logger
7
- from safe_eth.eth import EthereumClient, TxSpeed
7
+ from safe_eth.eth import TxSpeed
8
8
  from safe_eth.safe import Safe
9
9
  from safe_eth.safe.safe_tx import SafeTx
10
10
 
@@ -56,7 +56,6 @@ class SafeTransactionExecutor:
56
56
  config = Config().core
57
57
  self.max_retries = max_retries or config.safe_tx_max_retries
58
58
  self.gas_buffer = gas_buffer or config.safe_tx_gas_buffer
59
- self._client_cache: Dict[str, EthereumClient] = {}
60
59
 
61
60
  def execute_with_retry(
62
61
  self,
@@ -64,7 +63,7 @@ class SafeTransactionExecutor:
64
63
  safe_tx: SafeTx,
65
64
  signer_keys: List[str],
66
65
  operation_name: str = "safe_tx",
67
- ) -> Tuple[bool, str, Optional[Dict]]:
66
+ ) -> Tuple[bool, str, Optional[dict]]:
68
67
  """Execute SafeTx with full retry mechanism.
69
68
 
70
69
  Args:
@@ -303,10 +302,11 @@ class SafeTransactionExecutor:
303
302
 
304
303
  def _recreate_safe_client(self, safe_address: str) -> Safe:
305
304
  """Recreate Safe with current (possibly rotated) RPC."""
305
+ from iwa.plugins.gnosis.safe import get_ethereum_client
306
+
306
307
  rpc_url = self.chain_interface.current_rpc
307
- if rpc_url not in self._client_cache:
308
- self._client_cache[rpc_url] = EthereumClient(rpc_url)
309
- ethereum_client = self._client_cache[rpc_url]
308
+ # Use shared cache to prevent FD exhaustion
309
+ ethereum_client = get_ethereum_client(rpc_url)
310
310
  return Safe(safe_address, ethereum_client)
311
311
 
312
312
  def _is_nonce_error(self, error: Exception) -> bool:
@@ -29,6 +29,20 @@ warnings.filterwarnings(
29
29
 
30
30
  logger = configure_logger()
31
31
 
32
+ # Module-level session for connection pooling (avoids FD leak)
33
+ _session: requests.Session | None = None
34
+
35
+
36
+ def _get_session() -> requests.Session:
37
+ """Get or create the module-level HTTP session."""
38
+ global _session
39
+ if _session is None:
40
+ from iwa.core.http import create_retry_session
41
+
42
+ _session = create_retry_session()
43
+ return _session
44
+
45
+
32
46
  if TYPE_CHECKING:
33
47
  from cowdao_cowpy.common.chains import Chain
34
48
  from cowdao_cowpy.cow.swap import CompletedOrder
@@ -97,13 +111,14 @@ class CowSwap:
97
111
  logger.info(f"Checking order status for UID: {order.uid}")
98
112
 
99
113
  sleep_between_retries = 15
114
+ session = _get_session()
115
+ loop = asyncio.get_event_loop()
100
116
 
101
117
  while True:
102
118
  try:
103
- # Use a thread executor for blocking requests.get
104
- loop = asyncio.get_event_loop()
119
+ # Use a thread executor for blocking HTTP request
105
120
  response = await loop.run_in_executor(
106
- None, lambda: requests.get(order.url, timeout=60)
121
+ None, lambda s=session: s.get(order.url, timeout=60)
107
122
  )
108
123
  except Exception as e:
109
124
  logger.warning(f"Error checking order status: {e}")
@@ -1,6 +1,6 @@
1
1
  """Gnosis Safe interaction."""
2
2
 
3
- from typing import Callable, Optional
3
+ from typing import Callable, Dict, Optional
4
4
 
5
5
  from safe_eth.eth import EthereumClient
6
6
  from safe_eth.eth.constants import NULL_ADDRESS
@@ -12,6 +12,46 @@ from iwa.core.utils import configure_logger
12
12
 
13
13
  logger = configure_logger()
14
14
 
15
+ # Shared EthereumClient cache to prevent FD leaks
16
+ # Limited to MAX_CACHED_CLIENTS to avoid unbounded growth during RPC rotations
17
+ _ethereum_client_cache: Dict[str, EthereumClient] = {}
18
+ MAX_CACHED_CLIENTS = 3 # Keep only recent clients to limit FD usage
19
+
20
+
21
+ def _cleanup_old_clients(keep_url: str) -> None:
22
+ """Remove oldest cached clients to stay under limit."""
23
+ global _ethereum_client_cache
24
+ while len(_ethereum_client_cache) >= MAX_CACHED_CLIENTS:
25
+ # Remove oldest entry (first key in dict, preserves insertion order in Python 3.7+)
26
+ for old_url in list(_ethereum_client_cache.keys()):
27
+ if old_url != keep_url:
28
+ old_client = _ethereum_client_cache.pop(old_url)
29
+ # Try to close any underlying HTTP session
30
+ if hasattr(old_client, "w3") and hasattr(old_client.w3, "provider"):
31
+ provider = old_client.w3.provider
32
+ if hasattr(provider, "_request_kwargs"):
33
+ session = provider._request_kwargs.get("session")
34
+ if session and hasattr(session, "close"):
35
+ try:
36
+ session.close()
37
+ except Exception:
38
+ pass
39
+ logger.debug(f"Evicted EthereumClient for {old_url} from cache")
40
+ break
41
+
42
+
43
+ def get_ethereum_client(rpc_url: str) -> EthereumClient:
44
+ """Get a cached EthereumClient for the given RPC URL.
45
+
46
+ Reuses existing clients to prevent file descriptor exhaustion.
47
+ Limited to MAX_CACHED_CLIENTS to prevent unbounded cache growth
48
+ during RPC rotations.
49
+ """
50
+ if rpc_url not in _ethereum_client_cache:
51
+ _cleanup_old_clients(rpc_url)
52
+ _ethereum_client_cache[rpc_url] = EthereumClient(rpc_url)
53
+ return _ethereum_client_cache[rpc_url]
54
+
15
55
 
16
56
  class SafeMultisig:
17
57
  """Class to interact with Gnosis Safe multisig wallets.
@@ -20,17 +60,32 @@ class SafeMultisig:
20
60
  checking owners, thresholds, and building/sending multi-signature transactions.
21
61
  """
22
62
 
23
- def __init__(self, safe_account: StoredSafeAccount, chain_name: str):
24
- """Initialize the SafeMultisig instance."""
63
+ def __init__(
64
+ self,
65
+ safe_account: StoredSafeAccount,
66
+ chain_name: str,
67
+ ethereum_client: Optional[EthereumClient] = None,
68
+ ):
69
+ """Initialize the SafeMultisig instance.
70
+
71
+ Args:
72
+ safe_account: The Safe account to interact with.
73
+ chain_name: The chain name (e.g., 'gnosis').
74
+ ethereum_client: Optional pre-existing EthereumClient.
75
+ If not provided, uses a shared cached client.
76
+
77
+ """
25
78
  # Normalize chain comparison to be case-insensitive
26
79
  normalized_chains = [c.lower() for c in safe_account.chains]
27
80
  if chain_name.lower() not in normalized_chains:
28
81
  raise ValueError(f"Safe account is not deployed on chain: {chain_name}")
29
82
 
30
- from iwa.core.chain import ChainInterfaces
83
+ if ethereum_client is None:
84
+ from iwa.core.chain import ChainInterfaces
85
+
86
+ chain_interface = ChainInterfaces().get(chain_name.lower())
87
+ ethereum_client = get_ethereum_client(chain_interface.current_rpc)
31
88
 
32
- chain_interface = ChainInterfaces().get(chain_name.lower())
33
- ethereum_client = EthereumClient(chain_interface.current_rpc)
34
89
  self.multisig = Safe(safe_account.address, ethereum_client)
35
90
  self.ethereum_client = ethereum_client
36
91
 
@@ -175,19 +175,18 @@ async def test_check_cowswap_order_success(cowswap):
175
175
  mock_order = MagicMock()
176
176
  mock_order.url = "http://api/order"
177
177
 
178
- with patch("requests.get") as mock_get:
179
- mock_get.return_value.status_code = 200
180
- mock_get.return_value.json.return_value = {
181
- "status": "fulfilled",
182
- "executedSellAmount": "100",
183
- "executedBuyAmount": "90",
184
- }
185
-
186
- # Need to mock loop.run_in_executor since check_cowswap_order uses it
187
- # Or just let it run if requests.get is mocked?
188
- # check_cowswap_order calls loop.run_in_executor(None, lambda: requests.get(...))
189
- # This will run the lambda in a thread. The mock should work.
190
-
178
+ mock_response = MagicMock()
179
+ mock_response.status_code = 200
180
+ mock_response.json.return_value = {
181
+ "status": "fulfilled",
182
+ "executedSellAmount": "100",
183
+ "executedBuyAmount": "90",
184
+ }
185
+
186
+ mock_session = MagicMock()
187
+ mock_session.get.return_value = mock_response
188
+
189
+ with patch("iwa.plugins.gnosis.cow.swap._get_session", return_value=mock_session):
191
190
  result = await cowswap.check_cowswap_order(mock_order)
192
191
 
193
192
  assert result == {
@@ -203,10 +202,14 @@ async def test_check_cowswap_order_expired(cowswap):
203
202
  mock_order = MagicMock()
204
203
  mock_order.url = "http://api/order"
205
204
 
206
- with patch("requests.get") as mock_get:
207
- mock_get.return_value.status_code = 200
208
- mock_get.return_value.json.return_value = {"status": "expired"}
205
+ mock_response = MagicMock()
206
+ mock_response.status_code = 200
207
+ mock_response.json.return_value = {"status": "expired"}
208
+
209
+ mock_session = MagicMock()
210
+ mock_session.get.return_value = mock_response
209
211
 
212
+ with patch("iwa.plugins.gnosis.cow.swap._get_session", return_value=mock_session):
210
213
  result = await cowswap.check_cowswap_order(mock_order)
211
214
  assert result is None
212
215
 
@@ -217,20 +220,20 @@ async def test_check_cowswap_order_timeout(cowswap):
217
220
  mock_order = MagicMock()
218
221
  mock_order.url = "http://api/order"
219
222
 
220
- with patch("requests.get") as mock_get:
221
- mock_get.return_value.status_code = 200
222
- # Order is always open
223
- mock_get.return_value.json.return_value = {
224
- "status": "open",
225
- "executedSellAmount": "0",
226
- "validTo": 1000,
227
- }
228
-
229
- # Mock time to start at 900 and then jump to 1100 to trigger timeout
230
- with patch("time.time") as mock_time:
231
- mock_time.side_effect = [900, 1100]
232
- # Speed up retry sleep (asyncio.sleep)
233
- with patch("asyncio.sleep", new_callable=AsyncMock):
234
- result = await cowswap.check_cowswap_order(mock_order)
235
- assert result is None
236
- assert mock_time.call_count >= 2
223
+ mock_response = MagicMock()
224
+ mock_response.status_code = 200
225
+ # Order is always open, validTo = 1000
226
+ mock_response.json.return_value = {
227
+ "status": "open",
228
+ "executedSellAmount": "0",
229
+ "validTo": 1000,
230
+ }
231
+
232
+ mock_session = MagicMock()
233
+ mock_session.get.return_value = mock_response
234
+
235
+ with patch("iwa.plugins.gnosis.cow.swap._get_session", return_value=mock_session):
236
+ # Mock time to return >1060 (validTo + 60) to trigger timeout on first check
237
+ with patch("iwa.plugins.gnosis.cow.swap.time.time", return_value=1100):
238
+ result = await cowswap.check_cowswap_order(mock_order)
239
+ assert result is None
@@ -5,7 +5,12 @@ from unittest.mock import MagicMock, patch
5
5
  import pytest
6
6
 
7
7
  from iwa.core.models import StoredSafeAccount
8
- from iwa.plugins.gnosis.safe import SafeMultisig
8
+ from iwa.plugins.gnosis.safe import (
9
+ MAX_CACHED_CLIENTS,
10
+ SafeMultisig,
11
+ _ethereum_client_cache,
12
+ get_ethereum_client,
13
+ )
9
14
 
10
15
 
11
16
  @pytest.fixture
@@ -100,3 +105,83 @@ def test_send_tx(safe_account, mock_settings, mock_safe_eth):
100
105
  assert tx_hash == "0xHash"
101
106
 
102
107
  callback.assert_called_with("0xSafeTx")
108
+
109
+
110
+ class TestEthereumClientCache:
111
+ """Tests for EthereumClient caching to prevent FD exhaustion."""
112
+
113
+ def setup_method(self):
114
+ """Clear cache before each test."""
115
+ _ethereum_client_cache.clear()
116
+
117
+ def test_cache_reuses_client(self):
118
+ """Test that the same RPC URL returns the same cached client."""
119
+ with patch("iwa.plugins.gnosis.safe.EthereumClient") as mock_client_cls:
120
+ mock_client_cls.return_value = MagicMock()
121
+
122
+ client1 = get_ethereum_client("https://rpc1.example.com")
123
+ client2 = get_ethereum_client("https://rpc1.example.com")
124
+
125
+ assert client1 is client2
126
+ # Should only create one instance
127
+ assert mock_client_cls.call_count == 1
128
+
129
+ def test_cache_different_urls(self):
130
+ """Test that different URLs create different clients."""
131
+ with patch("iwa.plugins.gnosis.safe.EthereumClient") as mock_client_cls:
132
+ mock_client_cls.side_effect = lambda url: MagicMock(url=url)
133
+
134
+ client1 = get_ethereum_client("https://rpc1.example.com")
135
+ client2 = get_ethereum_client("https://rpc2.example.com")
136
+
137
+ assert client1 is not client2
138
+ assert mock_client_cls.call_count == 2
139
+
140
+ def test_cache_limit_enforced(self):
141
+ """Test that cache is limited to MAX_CACHED_CLIENTS."""
142
+ with patch("iwa.plugins.gnosis.safe.EthereumClient") as mock_client_cls:
143
+ # Create mock clients with closeable sessions
144
+ def create_mock_client(url):
145
+ client = MagicMock(url=url)
146
+ client.w3 = MagicMock()
147
+ client.w3.provider = MagicMock()
148
+ client.w3.provider._request_kwargs = {"session": MagicMock()}
149
+ return client
150
+
151
+ mock_client_cls.side_effect = create_mock_client
152
+
153
+ # Create more clients than the limit
154
+ urls = [f"https://rpc{i}.example.com" for i in range(MAX_CACHED_CLIENTS + 2)]
155
+ for url in urls:
156
+ get_ethereum_client(url)
157
+
158
+ # Cache should not exceed limit
159
+ assert len(_ethereum_client_cache) <= MAX_CACHED_CLIENTS
160
+
161
+ def test_cache_evicts_oldest(self):
162
+ """Test that oldest entries are evicted when limit is reached."""
163
+ with patch("iwa.plugins.gnosis.safe.EthereumClient") as mock_client_cls:
164
+
165
+ def create_mock_client(url):
166
+ client = MagicMock(url=url)
167
+ client.w3 = MagicMock()
168
+ client.w3.provider = MagicMock()
169
+ client.w3.provider._request_kwargs = {"session": MagicMock()}
170
+ return client
171
+
172
+ mock_client_cls.side_effect = create_mock_client
173
+
174
+ # Fill cache to limit
175
+ first_url = "https://first.example.com"
176
+ get_ethereum_client(first_url)
177
+ for i in range(1, MAX_CACHED_CLIENTS):
178
+ get_ethereum_client(f"https://rpc{i}.example.com")
179
+
180
+ assert first_url in _ethereum_client_cache
181
+
182
+ # Add one more - should evict the first
183
+ get_ethereum_client("https://new.example.com")
184
+
185
+ # First URL should be evicted
186
+ assert first_url not in _ethereum_client_cache
187
+ assert "https://new.example.com" in _ethereum_client_cache
@@ -598,6 +598,15 @@ class OlasServiceImporter:
598
598
  content = file_path.read_text().strip()
599
599
  keystore = json.loads(content)
600
600
 
601
+ # Handle operate format: keystore is stringified inside "private_key"
602
+ if "private_key" in keystore and isinstance(keystore["private_key"], str):
603
+ try:
604
+ inner_keystore = json.loads(keystore["private_key"])
605
+ if "crypto" in inner_keystore and "address" in inner_keystore:
606
+ keystore = inner_keystore
607
+ except json.JSONDecodeError:
608
+ pass # Not a nested keystore, continue with original
609
+
601
610
  # Validate it's a keystore
602
611
  if "crypto" not in keystore or "address" not in keystore:
603
612
  return None
@@ -67,10 +67,10 @@ class OlasPlugin(Plugin):
67
67
 
68
68
  """
69
69
  try:
70
- from safe_eth.eth import EthereumClient
71
70
  from safe_eth.safe import Safe
72
71
 
73
72
  from iwa.core.chain import ChainInterfaces
73
+ from iwa.plugins.gnosis.safe import get_ethereum_client
74
74
 
75
75
  try:
76
76
  chain_interface = ChainInterfaces().get(chain_name)
@@ -79,7 +79,8 @@ class OlasPlugin(Plugin):
79
79
  except ValueError:
80
80
  return None, None # Chain not supported/configured
81
81
 
82
- ethereum_client = EthereumClient(chain_interface.current_rpc)
82
+ # Reuse cached EthereumClient to prevent FD exhaustion
83
+ ethereum_client = get_ethereum_client(chain_interface.current_rpc)
83
84
  safe = Safe(safe_address, ethereum_client)
84
85
  owners = safe.retrieve_owners()
85
86
  return owners, True
@@ -545,10 +545,10 @@ class OlasView(Static):
545
545
  """Filter staking contracts based on bond requirements and slots."""
546
546
  import json
547
547
 
548
- from iwa.core.chain import ChainInterface
548
+ from iwa.core.chain import ChainInterfaces
549
549
  from iwa.plugins.olas.contracts.base import OLAS_ABI_PATH
550
550
 
551
- w3 = ChainInterface(self._chain).web3
551
+ w3 = ChainInterfaces().get(self._chain).web3
552
552
  with open(OLAS_ABI_PATH / "staking.json", "r") as f:
553
553
  abi = json.load(f)
554
554
 
@@ -677,14 +677,14 @@ class OlasView(Static):
677
677
  try:
678
678
  import json
679
679
 
680
- from iwa.core.chain import ChainInterface
680
+ from iwa.core.chain import ChainInterfaces
681
681
  from iwa.plugins.olas.constants import OLAS_TRADER_STAKING_CONTRACTS
682
682
  from iwa.plugins.olas.contracts.base import OLAS_ABI_PATH
683
683
 
684
684
  contracts_dict = OLAS_TRADER_STAKING_CONTRACTS.get(self._chain, {})
685
685
 
686
686
  # Load ABI and check slots for each contract
687
- w3 = ChainInterface(self._chain).web3
687
+ w3 = ChainInterfaces().get(self._chain).web3
688
688
  with open(OLAS_ABI_PATH / "staking.json", "r") as f:
689
689
  abi = json.load(f)
690
690
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.66
3
+ Version: 0.1.0
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
@@ -23,7 +23,7 @@ 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=lW8jmxQNVrUeoBUuYUx9X5kwUhr-Xw3MHS1RsGQeaRQ,29064
26
+ iwa/core/chain/interface.py,sha256=D_bDIQhQVfY9A2nLPpQ3bipmywoYsi2wVNPXPKutFec,30760
27
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
@@ -40,8 +40,8 @@ iwa/core/services/__init__.py,sha256=ab5pYzmu3LrZLTO5N-plx6Rp4R0hBEnbbzsgz84zWGM
40
40
  iwa/core/services/account.py,sha256=0l14qD8_-ZbN_hQUNa7bRZt0tkceHPPc4GHmB8UKqy4,2009
41
41
  iwa/core/services/balance.py,sha256=MSCEzPRDPlHIjaWD1A2X2oIuiMz5MFJjD7sSHUxQ8OM,3324
42
42
  iwa/core/services/plugin.py,sha256=GNNlbtELyHl7MNVChrypF76GYphxXduxDog4kx1MLi8,3277
43
- iwa/core/services/safe.py,sha256=vqvpk7aIqHljaG1zYYpmKdW4mi5OVuoyXcpReISPYM0,15744
44
- iwa/core/services/safe_executor.py,sha256=TqpDgtvh8d5cedYAKBj7s1SW7EnomTT9MW_GnYWMxDE,17157
43
+ iwa/core/services/safe.py,sha256=HG3yAN5IdNR45uuHfjAuaT4XVy_tiivoMTQLoEHlEwY,15701
44
+ iwa/core/services/safe_executor.py,sha256=uZIoE_VeB0B9b-HhZ5jNWXna1kr8e_LZ8qEt63kGxIU,17082
45
45
  iwa/core/services/transaction.py,sha256=FrGRWn1xo5rbGIr2ToZ2kPzapr3zmWW38oycyB87TK8,19971
46
46
  iwa/core/services/transfer/__init__.py,sha256=7p22xtwrH0murXWTuTGNw0WRKBDqjJEnnVaUpWd8Vfo,5589
47
47
  iwa/core/services/transfer/base.py,sha256=sohz-Ss2i-pGYGl4x9bD93cnYKcSvsXaXyvyRawvgQs,9043
@@ -58,20 +58,20 @@ iwa/plugins/__init__.py,sha256=zy-DjOZn8GSgIETN2X_GAb9O6yk71t6ZRzeUgoZ52KA,23
58
58
  iwa/plugins/gnosis/__init__.py,sha256=dpx0mE84eV-g5iZaH5nKivZJnoKWyRFX5rhdjowBwuU,114
59
59
  iwa/plugins/gnosis/cow_utils.py,sha256=iSvbfgTr2bCqRsUznKCWqmoTnyuX-WZX4oh0E-l3XBU,2263
60
60
  iwa/plugins/gnosis/plugin.py,sha256=AgkgOGYfnrcjWrPUiAvySMj6ITnss0SFXiEi6Z6fnMs,1885
61
- iwa/plugins/gnosis/safe.py,sha256=ye5GQhzKALPNiyJhr7lyrhDgdrDyIj_h3TN2QWI4Xds,5519
61
+ iwa/plugins/gnosis/safe.py,sha256=M5Pzyvs2XlygHeyPXoFAFRlECO2l3QeOpYZ38JtuPDM,7799
62
62
  iwa/plugins/gnosis/cow/__init__.py,sha256=lZN5QpIYWL67rE8r7z7zS9dlr8OqFrYeD9T4-RwUghU,224
63
63
  iwa/plugins/gnosis/cow/quotes.py,sha256=2emu4St016Gf7Nn5P2XW5CcSNxprKoVecdZQ016RpcY,5177
64
- iwa/plugins/gnosis/cow/swap.py,sha256=XgeMnTDnCiTXVlrrh2pXk4qmjJnFxIVnhZsa1K82uDQ,14729
64
+ iwa/plugins/gnosis/cow/swap.py,sha256=k_keiEa0rLjVUO5aI2-AHt-sXmgwo5eKO2MAuIA8tCg,15123
65
65
  iwa/plugins/gnosis/cow/types.py,sha256=-9VRiFhAkmN1iIJ95Pg7zLFSeXtkkW00sl13usxi3o8,470
66
- iwa/plugins/gnosis/tests/test_cow.py,sha256=50bj9kZ-PwrZXIdTTTNlien5WdDWHhxI44dcB89jOc8,8809
67
- iwa/plugins/gnosis/tests/test_safe.py,sha256=hQHVHBWQhGnuvzvx4U9fOWEwASJWwql42q6cfRcuAls,3218
66
+ iwa/plugins/gnosis/tests/test_cow.py,sha256=0GoejzZNQJmcUoFaSJL0Rph7fkIZnY5Vp7RdVLyal4I,8714
67
+ iwa/plugins/gnosis/tests/test_safe.py,sha256=ZgqGSsCOWVbqz5WNZ1EQzsgYQMAwJtJ4-7HQNiOlUGo,6584
68
68
  iwa/plugins/olas/__init__.py,sha256=_NhBczzM61fhGYwGhnWfEeL8Jywyy_730GASe2BxzeQ,106
69
69
  iwa/plugins/olas/constants.py,sha256=BbEDho_TAh10cCGsrlk2vP1OVrS_ZWBE_cAEITd_658,7838
70
70
  iwa/plugins/olas/events.py,sha256=HHjYu4pN3tuZATIh8vGWWzDb7z9wuqhsaTqI3_4H0-I,6086
71
- iwa/plugins/olas/importer.py,sha256=5xTtlQe5hO5bUCOg1sRuFkN2UcohYdJEQwfMm2JckyI,42088
71
+ iwa/plugins/olas/importer.py,sha256=5kFWfzstkIhy6hWevYdbuHUMOnhqpm2STXiF3-pYuYo,42604
72
72
  iwa/plugins/olas/mech_reference.py,sha256=CaSCpQnQL4F7wOG6Ox6Zdoy-uNEQ78YBwVLILQZKL8Q,5782
73
73
  iwa/plugins/olas/models.py,sha256=VtDjSyc63Yxs3aManmALrcf7asehdQ5f-5Y6MtAdWIk,4056
74
- iwa/plugins/olas/plugin.py,sha256=kz21CxIGQw3Is6HC2dvKvFRIm9m1FRKHO0YgUuDEplQ,15650
74
+ iwa/plugins/olas/plugin.py,sha256=tW8GCLcWRAxF93hW5awdTwC9njhZiSWKzowb0bWSDRs,15738
75
75
  iwa/plugins/olas/contracts/activity_checker.py,sha256=OXh0SFPGfcpeD665ay-I19LqcIx38qEz8o62dw0A9zE,5361
76
76
  iwa/plugins/olas/contracts/base.py,sha256=y73aQbDq6l4zUpz_eQAg4MsLkTAEqjjupXlcvxjfgCI,240
77
77
  iwa/plugins/olas/contracts/mech.py,sha256=dXYtyORc-oiu9ga5PtTquOFkoakb6BLGKvlUsteygIg,2767
@@ -121,7 +121,7 @@ iwa/plugins/olas/tests/test_service_staking.py,sha256=78yyPoLo51N1aQyDxjzj7I0a26
121
121
  iwa/plugins/olas/tests/test_staking_integration.py,sha256=QCBQf6P2ZmmsEGt2k8W2r53lG2aVRuoMJE-aFxVDLss,9701
122
122
  iwa/plugins/olas/tests/test_staking_validation.py,sha256=uug64jFcXYJ3Nw_lNa3O4fnhNr5wAWHHIrchSbR2MVE,4020
123
123
  iwa/plugins/olas/tui/__init__.py,sha256=5ZRsbC7J3z1xfkZRiwr4bLEklf78rNVjdswe2p7SlS8,28
124
- iwa/plugins/olas/tui/olas_view.py,sha256=OlhciDK1Ni4BdzggqTzQeYnP2azB-We02hH6jQhbZuU,37388
124
+ iwa/plugins/olas/tui/olas_view.py,sha256=dgZjfXCWsRRdHpygHfSOCJZFWZrgrVyieq-iYgDkK3w,37404
125
125
  iwa/tools/__init__.py,sha256=jQyuwDQGRigSe7S9JMb4yK3CXPgZFJNffzt6N2v9PU0,21
126
126
  iwa/tools/check_profile.py,sha256=0LAv9wx4wMM610mX88-6tIoDi2I5LDzh0W9nkprt42s,2177
127
127
  iwa/tools/drain_accounts.py,sha256=Xd0ephHENms2f5G7IotpFBazPpKrvXoRurctS7fYrc4,1760
@@ -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=GunKEAzcbzL7FoUGMtEl8wqiqwYwA5lB9sOhfCNj0TA,16312
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.66.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
169
+ iwa-0.1.0.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,7 +176,7 @@ 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=bgqGM8wJGZjc-BOX6i0K4sh06KCJl-6UAvrwl8x24lA,8324
179
+ tests/test_chain_interface.py,sha256=DhPyIMSWgt0eD8__dW9JB0xb-H0P3TndnOsKeAb3fRg,10501
180
180
  tests/test_chain_interface_coverage.py,sha256=fvrVvw8-DMwdsSFKQHUhpbfutrVRxnnTc-tjB7Bb-jo,3327
181
181
  tests/test_chainlist_enrichment.py,sha256=hxfYjI54hT2pBXeacmQkLJGEPyuaAwDtNL5sEVZURp8,22065
182
182
  tests/test_cli.py,sha256=Pl4RC2xp1omiJUnL3Dza6pCmIoO29LJ0vGw33_ZpT5c,3980
@@ -203,10 +203,10 @@ tests/test_rpc_efficiency.py,sha256=mNuCoa5r6lSEyTqcRX98oz-huoKMTUlKM2UcOHlTQ6M,
203
203
  tests/test_rpc_rate_limit.py,sha256=3P_Nd9voFmz-4r_Et-vw8W-Esbq5elSYmRBSOtJGx1Y,1014
204
204
  tests/test_rpc_rotation.py,sha256=a1cFKsf0fo-73_MSDnTuU6Zpv7bJHjrCVu3ANe8PXDU,12541
205
205
  tests/test_rpc_view.py,sha256=sgZ53KEHl8VGb7WKYa0VI7Cdxbf8JH1SdroHYbWHjfQ,2031
206
- tests/test_safe_coverage.py,sha256=KBxKz64XkK8CgN0N0LTNVKakf8Wg8EpghcBlLmDFmLs,6119
206
+ tests/test_safe_coverage.py,sha256=7t77jZeATQJwyVM_CN9EK7EJLGSGJDnOyKo7xrvuFYA,6278
207
207
  tests/test_safe_executor.py,sha256=xyiNmPo0Ux3jIcdz5AT98YSmha7Pn3HUDoBtBF6AYRE,25032
208
208
  tests/test_safe_integration.py,sha256=WWAKDio3N-CFyr5RRvphbOPdu3TI9WSM8IesfbFbvWQ,5363
209
- tests/test_safe_service.py,sha256=5ULlj0fPZRwg-4fCBJplhm4Msr_Beof7W-Zf_JljZc8,5782
209
+ tests/test_safe_service.py,sha256=8nrCRuDY3na23HBEvy0UO5iesCsY3ZsuvghyCVuEWLo,6004
210
210
  tests/test_service_manager_integration.py,sha256=I_BLUzEKrVTyg_8jqsUK0oFD3aQVPCRJ7z0gY8P-j04,2354
211
211
  tests/test_service_manager_structure.py,sha256=zK506ucCXCBHcjPYKrKEuK1bgq0xsbawyL8Y-wahXf8,868
212
212
  tests/test_service_transaction.py,sha256=IeqYhmRD-pIXffBJrBQwfPx-qnfNEJs0iPM3eCb8MLo,7054
@@ -224,8 +224,8 @@ tests/test_utils.py,sha256=vkP49rYNI8BRzLpWR3WnKdDr8upeZjZcs7Rx0pjbQMo,1292
224
224
  tests/test_workers.py,sha256=MInwdkFY5LdmFB3o1odIaSD7AQZb3263hNafO1De5PE,2793
225
225
  tools/create_and_stake_service.py,sha256=1xwy_bJQI1j9yIQ968Oc9Db_F6mk1659LuuZntTASDE,3742
226
226
  tools/verify_drain.py,sha256=PkMjblyOOAuQge88FwfEzRtCYeEtJxXhPBmtQYCoQ-8,6743
227
- iwa-0.0.66.dist-info/METADATA,sha256=XgG92uv_4y_Td88AjTYAknzLghILTIyM7KwLSbS3MUI,7337
228
- iwa-0.0.66.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
229
- iwa-0.0.66.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
230
- iwa-0.0.66.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
231
- iwa-0.0.66.dist-info/RECORD,,
227
+ iwa-0.1.0.dist-info/METADATA,sha256=w3bjDMNL_NKmol-SCRkwdaY24JaW6LbyL1RZF9jsBiI,7336
228
+ iwa-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
229
+ iwa-0.1.0.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
230
+ iwa-0.1.0.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
231
+ iwa-0.1.0.dist-info/RECORD,,
@@ -208,3 +208,58 @@ def test_reset_rpc_failure_counts(mock_chain_interface):
208
208
  interface.reset_rpc_failure_counts()
209
209
 
210
210
  assert interface._rpc_backoff_until == {}
211
+
212
+
213
+ def test_is_fd_exhaustion_error():
214
+ """Test FD exhaustion error detection."""
215
+ with patch("iwa.core.chain.interface.get_rate_limiter"):
216
+ with patch("iwa.core.chain.interface.RateLimitedWeb3"):
217
+ with patch("iwa.core.chain.models.Gnosis") as mock_gnosis_cls:
218
+ mock_gnosis = mock_gnosis_cls.return_value
219
+ mock_gnosis.name = "gnosis"
220
+ mock_gnosis.rpc = "https://rpc.example.com"
221
+ mock_gnosis.rpcs = ["https://rpc.example.com"]
222
+ mock_gnosis.chain_id = 100
223
+ mock_gnosis.native_currency = "xDAI"
224
+ mock_gnosis.tokens = {}
225
+ mock_gnosis.contracts = {}
226
+
227
+ interface = ChainInterface(mock_gnosis)
228
+
229
+ # Should detect FD exhaustion errors
230
+ assert interface._is_fd_exhaustion_error(
231
+ Exception("SSLError(OSError(24, 'Too many open files'))")
232
+ )
233
+ assert interface._is_fd_exhaustion_error(
234
+ Exception("OSError(24, 'Too many open files')")
235
+ )
236
+ assert interface._is_fd_exhaustion_error(
237
+ Exception("errno 24: too many open files")
238
+ )
239
+
240
+ # Should not detect other errors as FD exhaustion
241
+ assert not interface._is_fd_exhaustion_error(Exception("connection timeout"))
242
+ assert not interface._is_fd_exhaustion_error(Exception("429 rate limit"))
243
+ assert not interface._is_fd_exhaustion_error(Exception("500 server error"))
244
+
245
+
246
+ def test_handle_fd_exhaustion_no_rotation(mock_chain_interface):
247
+ """Test FD exhaustion pauses all RPCs without rotating."""
248
+ interface, _ = mock_chain_interface
249
+ interface.rotate_rpc = MagicMock(return_value=True)
250
+
251
+ # Simulate FD exhaustion error
252
+ error = Exception("SSLError(OSError(24, 'Too many open files'))")
253
+ result = interface._handle_rpc_error(error)
254
+
255
+ # Should NOT rotate (rotation creates more connections)
256
+ interface.rotate_rpc.assert_not_called()
257
+
258
+ # Should mark for retry after backoff
259
+ assert result["should_retry"]
260
+ assert result["is_fd_exhaustion"]
261
+ assert not result["rotated"]
262
+
263
+ # All RPCs should be in backoff
264
+ for i in range(len(interface.chain.rpcs)):
265
+ assert not interface._is_rpc_healthy(i)
@@ -136,9 +136,12 @@ def test_redeploy_safes(safe_service, mock_deps):
136
136
  with patch("iwa.core.chain.models.secrets") as mock_settings:
137
137
  mock_settings.gnosis_rpc.get_secret_value.return_value = "http://rpc"
138
138
 
139
- with patch("iwa.core.services.safe.EthereumClient") as mock_eth_client:
139
+ # Patch the shared EthereumClient cache function
140
+ with patch("iwa.plugins.gnosis.safe.get_ethereum_client") as mock_get_client:
140
141
  with patch.object(safe_service, "create_safe") as mock_create:
141
- mock_w3 = mock_eth_client.return_value.w3
142
+ mock_eth_client = MagicMock()
143
+ mock_get_client.return_value = mock_eth_client
144
+ mock_w3 = mock_eth_client.w3
142
145
 
143
146
  # Case 1: Code exists (no redeploy)
144
147
  mock_w3.eth.get_code.return_value = b"code"
@@ -51,6 +51,7 @@ def mock_dependencies():
51
51
  """Mock external dependencies (Safe, EthereumClient, etc)."""
52
52
  with (
53
53
  patch("iwa.core.services.safe.EthereumClient") as mock_client,
54
+ patch("iwa.plugins.gnosis.safe.get_ethereum_client") as mock_get_client,
54
55
  patch("iwa.core.services.safe.Safe") as mock_safe,
55
56
  patch("iwa.core.services.safe.ProxyFactory") as mock_proxy_factory,
56
57
  patch("iwa.core.services.safe.log_transaction") as mock_log,
@@ -58,6 +59,8 @@ def mock_dependencies():
58
59
  patch("iwa.core.services.safe.get_safe_proxy_factory_address") as mock_factory,
59
60
  patch("time.sleep"), # Avoid any retry delays
60
61
  ):
62
+ # Link get_ethereum_client to return the same mock as EthereumClient
63
+ mock_get_client.return_value = mock_client.return_value
61
64
  # Setup Safe creation return
62
65
  mock_create_tx = MagicMock()
63
66
  # Valid Checksum Address - New Safe (Matches Pydantic output)
File without changes