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.
- iwa/core/chain/interface.py +35 -0
- iwa/core/services/safe.py +9 -12
- iwa/core/services/safe_executor.py +7 -7
- iwa/plugins/gnosis/cow/swap.py +18 -3
- iwa/plugins/gnosis/safe.py +61 -6
- iwa/plugins/gnosis/tests/test_cow.py +36 -33
- iwa/plugins/gnosis/tests/test_safe.py +86 -1
- iwa/plugins/olas/importer.py +9 -0
- iwa/plugins/olas/plugin.py +3 -2
- iwa/plugins/olas/tui/olas_view.py +4 -4
- {iwa-0.0.66.dist-info → iwa-0.1.0.dist-info}/METADATA +1 -1
- {iwa-0.0.66.dist-info → iwa-0.1.0.dist-info}/RECORD +19 -19
- tests/test_chain_interface.py +55 -0
- tests/test_safe_coverage.py +5 -2
- tests/test_safe_service.py +3 -0
- {iwa-0.0.66.dist-info → iwa-0.1.0.dist-info}/WHEEL +0 -0
- {iwa-0.0.66.dist-info → iwa-0.1.0.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.66.dist-info → iwa-0.1.0.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.66.dist-info → iwa-0.1.0.dist-info}/top_level.txt +0 -0
iwa/core/chain/interface.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
4
|
+
from typing import TYPE_CHECKING, List, Optional, Tuple
|
|
5
5
|
|
|
6
6
|
from loguru import logger
|
|
7
|
-
from safe_eth.eth import
|
|
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[
|
|
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
|
-
|
|
308
|
-
|
|
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:
|
iwa/plugins/gnosis/cow/swap.py
CHANGED
|
@@ -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
|
|
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:
|
|
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}")
|
iwa/plugins/gnosis/safe.py
CHANGED
|
@@ -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__(
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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
|
iwa/plugins/olas/importer.py
CHANGED
|
@@ -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
|
iwa/plugins/olas/plugin.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
548
|
+
from iwa.core.chain import ChainInterfaces
|
|
549
549
|
from iwa.plugins.olas.contracts.base import OLAS_ABI_PATH
|
|
550
550
|
|
|
551
|
-
w3 =
|
|
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
|
|
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 =
|
|
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
|
|
|
@@ -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=
|
|
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=
|
|
44
|
-
iwa/core/services/safe_executor.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
67
|
-
iwa/plugins/gnosis/tests/test_safe.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
228
|
-
iwa-0.0.
|
|
229
|
-
iwa-0.0.
|
|
230
|
-
iwa-0.0.
|
|
231
|
-
iwa-0.0.
|
|
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,,
|
tests/test_chain_interface.py
CHANGED
|
@@ -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)
|
tests/test_safe_coverage.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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"
|
tests/test_safe_service.py
CHANGED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|