iwa 0.0.58__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.
- iwa/core/chain/interface.py +32 -21
- iwa/core/chain/rate_limiter.py +0 -6
- iwa/core/chainlist.py +15 -10
- iwa/core/cli.py +3 -0
- iwa/core/contracts/cache.py +1 -1
- iwa/core/contracts/contract.py +1 -0
- iwa/core/contracts/decoder.py +10 -4
- iwa/core/http.py +31 -0
- iwa/core/ipfs.py +11 -19
- iwa/core/keys.py +10 -4
- iwa/core/models.py +1 -3
- iwa/core/pricing.py +3 -21
- iwa/core/rpc_monitor.py +1 -0
- iwa/core/services/balance.py +0 -1
- iwa/core/services/safe.py +8 -2
- iwa/core/services/safe_executor.py +52 -18
- iwa/core/services/transaction.py +32 -12
- iwa/core/services/transfer/erc20.py +0 -1
- iwa/core/services/transfer/native.py +1 -1
- iwa/core/tests/test_gnosis_fee.py +6 -2
- iwa/core/tests/test_ipfs.py +1 -1
- iwa/core/tests/test_regression_fixes.py +3 -6
- iwa/core/utils.py +2 -0
- iwa/core/wallet.py +3 -1
- iwa/plugins/olas/constants.py +15 -5
- iwa/plugins/olas/contracts/activity_checker.py +3 -3
- iwa/plugins/olas/contracts/staking.py +0 -1
- iwa/plugins/olas/events.py +15 -13
- iwa/plugins/olas/importer.py +26 -20
- iwa/plugins/olas/plugin.py +16 -14
- iwa/plugins/olas/service_manager/drain.py +1 -3
- iwa/plugins/olas/service_manager/lifecycle.py +9 -9
- iwa/plugins/olas/service_manager/staking.py +11 -6
- iwa/plugins/olas/tests/test_olas_archiving.py +25 -15
- iwa/plugins/olas/tests/test_olas_integration.py +49 -29
- iwa/plugins/olas/tests/test_service_manager.py +8 -10
- iwa/plugins/olas/tests/test_service_manager_errors.py +5 -4
- iwa/plugins/olas/tests/test_service_manager_flows.py +6 -5
- iwa/plugins/olas/tests/test_service_staking.py +64 -38
- iwa/tools/drain_accounts.py +2 -1
- iwa/tools/reset_env.py +2 -1
- iwa/tools/test_chainlist.py +5 -1
- iwa/tui/screens/wallets.py +1 -3
- iwa/web/routers/olas/services.py +10 -5
- {iwa-0.0.58.dist-info → iwa-0.0.59.dist-info}/METADATA +1 -1
- {iwa-0.0.58.dist-info → iwa-0.0.59.dist-info}/RECORD +58 -57
- tests/test_balance_service.py +0 -2
- tests/test_chain.py +1 -2
- tests/test_rate_limiter_retry.py +2 -7
- tests/test_rpc_efficiency.py +4 -1
- tests/test_rpc_rate_limit.py +1 -0
- tests/test_rpc_rotation.py +4 -4
- tests/test_safe_executor.py +76 -50
- tests/test_safe_integration.py +11 -6
- {iwa-0.0.58.dist-info → iwa-0.0.59.dist-info}/WHEEL +0 -0
- {iwa-0.0.58.dist-info → iwa-0.0.59.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.58.dist-info → iwa-0.0.59.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.58.dist-info → iwa-0.0.59.dist-info}/top_level.txt +0 -0
iwa/core/chain/interface.py
CHANGED
|
@@ -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
|
|
|
@@ -48,6 +50,7 @@ class ChainInterface:
|
|
|
48
50
|
|
|
49
51
|
self._initial_block = 0
|
|
50
52
|
self._rotation_lock = threading.Lock()
|
|
53
|
+
self._session = requests.Session()
|
|
51
54
|
self._init_web3()
|
|
52
55
|
|
|
53
56
|
@property
|
|
@@ -289,20 +292,17 @@ class ChainInterface:
|
|
|
289
292
|
|
|
290
293
|
def rotate_rpc(self) -> bool:
|
|
291
294
|
"""Rotate to the next available RPC."""
|
|
292
|
-
# Minimum time between rotations to prevent cascade rotations from parallel requests
|
|
293
|
-
# failing simultaneously
|
|
294
|
-
cooldown_seconds = 2.0
|
|
295
|
-
|
|
296
295
|
with self._rotation_lock:
|
|
297
296
|
if not self.chain.rpcs or len(self.chain.rpcs) <= 1:
|
|
298
297
|
return False
|
|
299
298
|
|
|
300
299
|
# Cooldown: prevent cascade rotations from in-flight requests
|
|
301
300
|
now = time.monotonic()
|
|
302
|
-
|
|
301
|
+
elapsed = now - self._last_rotation_time
|
|
302
|
+
if elapsed < self.ROTATION_COOLDOWN_SECONDS:
|
|
303
303
|
logger.debug(
|
|
304
304
|
f"RPC rotation skipped for {self.chain.name} (cooldown active, "
|
|
305
|
-
f"{
|
|
305
|
+
f"{self.ROTATION_COOLDOWN_SECONDS - elapsed:.1f}s remaining)"
|
|
306
306
|
)
|
|
307
307
|
return False
|
|
308
308
|
|
|
@@ -326,7 +326,11 @@ class ChainInterface:
|
|
|
326
326
|
def _init_web3_under_lock(self):
|
|
327
327
|
"""Internal non-thread-safe web3 initialization."""
|
|
328
328
|
rpc_url = self.chain.rpcs[self._current_rpc_index] if self.chain.rpcs else ""
|
|
329
|
-
raw_web3 = Web3(
|
|
329
|
+
raw_web3 = Web3(
|
|
330
|
+
Web3.HTTPProvider(
|
|
331
|
+
rpc_url, request_kwargs={"timeout": DEFAULT_RPC_TIMEOUT}, session=self._session
|
|
332
|
+
)
|
|
333
|
+
)
|
|
330
334
|
|
|
331
335
|
# Use duck typing to check if current web3 is a RateLimitedWeb3 wrapper
|
|
332
336
|
if hasattr(self, "web3") and hasattr(self.web3, "set_backend"):
|
|
@@ -409,7 +413,9 @@ class ChainInterface:
|
|
|
409
413
|
except Exception:
|
|
410
414
|
return address[:6] + "..." + address[-4:]
|
|
411
415
|
|
|
412
|
-
def get_token_decimals(
|
|
416
|
+
def get_token_decimals(
|
|
417
|
+
self, address: EthereumAddress, fallback_to_18: bool = True
|
|
418
|
+
) -> Optional[int]:
|
|
413
419
|
"""Get token decimals for an address.
|
|
414
420
|
|
|
415
421
|
Args:
|
|
@@ -426,7 +432,15 @@ class ChainInterface:
|
|
|
426
432
|
# Use _web3 directly to ensure current provider after RPC rotation
|
|
427
433
|
contract = self.web3._web3.eth.contract(
|
|
428
434
|
address=self.web3.to_checksum_address(address),
|
|
429
|
-
abi=[
|
|
435
|
+
abi=[
|
|
436
|
+
{
|
|
437
|
+
"constant": True,
|
|
438
|
+
"inputs": [],
|
|
439
|
+
"name": "decimals",
|
|
440
|
+
"outputs": [{"type": "uint8"}],
|
|
441
|
+
"type": "function",
|
|
442
|
+
}
|
|
443
|
+
],
|
|
430
444
|
)
|
|
431
445
|
return contract.functions.decimals().call()
|
|
432
446
|
except Exception:
|
|
@@ -475,8 +489,10 @@ class ChainInterface:
|
|
|
475
489
|
# Contract calls already have the target address in the contract object
|
|
476
490
|
if not built_method and "to" in tx_params:
|
|
477
491
|
params["to"] = tx_params["to"]
|
|
478
|
-
elif
|
|
479
|
-
|
|
492
|
+
elif (
|
|
493
|
+
not built_method and "to" in params
|
|
494
|
+
): # Fallback if added to params earlier (though not here yet)
|
|
495
|
+
pass
|
|
480
496
|
|
|
481
497
|
# Determine gas
|
|
482
498
|
if built_method:
|
|
@@ -489,11 +505,7 @@ class ChainInterface:
|
|
|
489
505
|
# Native transfer - dynamic estimation
|
|
490
506
|
try:
|
|
491
507
|
# web3.eth.estimate_gas returns gas for the dict it receives
|
|
492
|
-
est_params = {
|
|
493
|
-
"from": params["from"],
|
|
494
|
-
"to": params["to"],
|
|
495
|
-
"value": params["value"]
|
|
496
|
-
}
|
|
508
|
+
est_params = {"from": params["from"], "to": params["to"], "value": params["value"]}
|
|
497
509
|
# Remove None 'to' for contract creation simulation if needed, but usually send() has to
|
|
498
510
|
if not est_params["to"]:
|
|
499
511
|
est_params.pop("to")
|
|
@@ -501,7 +513,9 @@ class ChainInterface:
|
|
|
501
513
|
estimated = self.web3.eth.estimate_gas(est_params)
|
|
502
514
|
# Apply 10% buffer for safety
|
|
503
515
|
params["gas"] = int(estimated * 1.1)
|
|
504
|
-
logger.debug(
|
|
516
|
+
logger.debug(
|
|
517
|
+
f"[GAS] Estimated native transfer gas: {params['gas']} (raw: {estimated})"
|
|
518
|
+
)
|
|
505
519
|
except Exception as e:
|
|
506
520
|
logger.debug(f"[GAS] Native estimation failed, fallback to 21000: {e}")
|
|
507
521
|
params["gas"] = 21_000
|
|
@@ -533,10 +547,7 @@ class ChainInterface:
|
|
|
533
547
|
# Buffer max_fee to handle base fee expansion
|
|
534
548
|
max_fee = int(base_fee * 1.5) + max_priority_fee
|
|
535
549
|
|
|
536
|
-
return {
|
|
537
|
-
"maxFeePerGas": max_fee,
|
|
538
|
-
"maxPriorityFeePerGas": max_priority_fee
|
|
539
|
-
}
|
|
550
|
+
return {"maxFeePerGas": max_fee, "maxPriorityFeePerGas": max_priority_fee}
|
|
540
551
|
except Exception as e:
|
|
541
552
|
logger.debug(f"Failed to calculate EIP-1559 fees: {e}, falling back to legacy")
|
|
542
553
|
|
iwa/core/chain/rate_limiter.py
CHANGED
|
@@ -188,12 +188,10 @@ class RateLimitedEth:
|
|
|
188
188
|
|
|
189
189
|
def _execute_with_retry(self, method, method_name, *args, **kwargs):
|
|
190
190
|
"""Execute read operation with retry logic."""
|
|
191
|
-
last_error = None
|
|
192
191
|
for attempt in range(self.DEFAULT_READ_RETRIES + 1):
|
|
193
192
|
try:
|
|
194
193
|
return method(*args, **kwargs)
|
|
195
194
|
except Exception as e:
|
|
196
|
-
last_error = e
|
|
197
195
|
# Use chain interface to handle error (logging, rotation, etc.)
|
|
198
196
|
result = self._chain_interface._handle_rpc_error(e)
|
|
199
197
|
|
|
@@ -206,10 +204,6 @@ class RateLimitedEth:
|
|
|
206
204
|
)
|
|
207
205
|
time.sleep(delay)
|
|
208
206
|
|
|
209
|
-
if last_error:
|
|
210
|
-
raise last_error
|
|
211
|
-
raise RuntimeError(f"{method_name} failed unexpectedly")
|
|
212
|
-
|
|
213
207
|
|
|
214
208
|
class RateLimitedWeb3:
|
|
215
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(
|
|
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(
|
|
92
|
+
raw_rpcs = chain_data.get("rpc", [])
|
|
92
93
|
nodes = []
|
|
93
94
|
for rpc in raw_rpcs:
|
|
94
|
-
nodes.append(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
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")
|
iwa/core/contracts/cache.py
CHANGED
iwa/core/contracts/contract.py
CHANGED
iwa/core/contracts/decoder.py
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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(
|
|
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
|
@@ -12,14 +12,15 @@ from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
|
|
|
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
|
|
|
17
18
|
if TYPE_CHECKING:
|
|
18
19
|
import requests
|
|
19
20
|
|
|
20
|
-
# Global
|
|
21
|
+
# Global persistent sessions (reused across calls to prevent FD leaks)
|
|
21
22
|
_SYNC_SESSION: Optional["requests.Session"] = None
|
|
22
|
-
|
|
23
|
+
_ASYNC_SESSION: Optional[aiohttp.ClientSession] = None
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def _compute_cid_v1_hex(data: bytes) -> str:
|
|
@@ -63,10 +64,13 @@ async def push_to_ipfs_async(
|
|
|
63
64
|
form = aiohttp.FormData()
|
|
64
65
|
form.add_field("file", data, filename="data", content_type="application/octet-stream")
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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()
|
|
70
74
|
|
|
71
75
|
cid_str = result["Hash"]
|
|
72
76
|
cid = CID.decode(cid_str)
|
|
@@ -90,22 +94,10 @@ def push_to_ipfs_sync(
|
|
|
90
94
|
:param pin: Whether to pin the content (default True).
|
|
91
95
|
:return: Tuple of (CIDv1 string, CIDv1 hex representation).
|
|
92
96
|
"""
|
|
93
|
-
import requests
|
|
94
|
-
from requests.adapters import HTTPAdapter
|
|
95
|
-
from urllib3.util.retry import Retry
|
|
96
|
-
|
|
97
97
|
global _SYNC_SESSION
|
|
98
98
|
|
|
99
99
|
if _SYNC_SESSION is None:
|
|
100
|
-
_SYNC_SESSION =
|
|
101
|
-
retry_strategy = Retry(
|
|
102
|
-
total=3,
|
|
103
|
-
backoff_factor=1,
|
|
104
|
-
status_forcelist=[429, 500, 502, 503, 504],
|
|
105
|
-
)
|
|
106
|
-
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
107
|
-
_SYNC_SESSION.mount("http://", adapter)
|
|
108
|
-
_SYNC_SESSION.mount("https://", adapter)
|
|
100
|
+
_SYNC_SESSION = create_retry_session()
|
|
109
101
|
|
|
110
102
|
url = api_url or Config().core.ipfs_api_url
|
|
111
103
|
endpoint = f"{url}/api/v0/add"
|
iwa/core/keys.py
CHANGED
|
@@ -249,7 +249,7 @@ class KeyStorage(BaseModel):
|
|
|
249
249
|
|
|
250
250
|
with open(self._path, "w", encoding="utf-8") as f:
|
|
251
251
|
# Use mode='json' to ensure all types (EthereumAddress) are correctly serialized
|
|
252
|
-
json.dump(self.model_dump(mode=
|
|
252
|
+
json.dump(self.model_dump(mode="json"), f, indent=4)
|
|
253
253
|
f.flush()
|
|
254
254
|
os.fsync(f.fileno()) # Force write to disk (critical for Docker volumes)
|
|
255
255
|
|
|
@@ -376,10 +376,14 @@ class KeyStorage(BaseModel):
|
|
|
376
376
|
# Check for duplicate tags
|
|
377
377
|
for existing in self.accounts.values():
|
|
378
378
|
if existing.tag == account.tag and existing.address != account.address:
|
|
379
|
-
raise ValueError(
|
|
379
|
+
raise ValueError(
|
|
380
|
+
f"Tag '{account.tag}' is already used by address {existing.address}"
|
|
381
|
+
)
|
|
380
382
|
|
|
381
383
|
self.accounts[account.address] = account
|
|
382
|
-
logger.info(
|
|
384
|
+
logger.info(
|
|
385
|
+
f"[KeyStorage] Registering account: tag='{account.tag}', address={account.address}"
|
|
386
|
+
)
|
|
383
387
|
self.save()
|
|
384
388
|
|
|
385
389
|
def get_pending_mnemonic(self) -> Optional[str]:
|
|
@@ -460,7 +464,9 @@ class KeyStorage(BaseModel):
|
|
|
460
464
|
|
|
461
465
|
old_tag = account.tag
|
|
462
466
|
account.tag = new_tag
|
|
463
|
-
logger.info(
|
|
467
|
+
logger.info(
|
|
468
|
+
f"[KeyStorage] Renaming account: '{old_tag}' -> '{new_tag}' (address={account.address})"
|
|
469
|
+
)
|
|
464
470
|
self.save()
|
|
465
471
|
|
|
466
472
|
def _get_private_key(self, address: str) -> Optional[str]:
|
iwa/core/models.py
CHANGED
|
@@ -80,9 +80,7 @@ class CoreConfig(BaseModel):
|
|
|
80
80
|
tenderly_olas_funds: float = Field(default=100000.0, description="OLAS amount for vNet funding")
|
|
81
81
|
|
|
82
82
|
# Safe Transaction Retry System
|
|
83
|
-
safe_tx_max_retries: int = Field(
|
|
84
|
-
default=6, description="Maximum retries for Safe transactions"
|
|
85
|
-
)
|
|
83
|
+
safe_tx_max_retries: int = Field(default=6, description="Maximum retries for Safe transactions")
|
|
86
84
|
safe_tx_gas_buffer: float = Field(
|
|
87
85
|
default=1.5, description="Gas buffer multiplier for Safe transactions"
|
|
88
86
|
)
|
iwa/core/pricing.py
CHANGED
|
@@ -4,9 +4,9 @@ import time
|
|
|
4
4
|
from datetime import datetime, timedelta
|
|
5
5
|
from typing import Dict, Optional
|
|
6
6
|
|
|
7
|
-
import requests
|
|
8
7
|
from loguru import logger
|
|
9
8
|
|
|
9
|
+
from iwa.core.http import create_retry_session
|
|
10
10
|
from iwa.core.secrets import secrets
|
|
11
11
|
|
|
12
12
|
# Global cache shared across all PriceService instances
|
|
@@ -28,28 +28,12 @@ class PriceService:
|
|
|
28
28
|
if self.secrets.coingecko_api_key
|
|
29
29
|
else None
|
|
30
30
|
)
|
|
31
|
-
self.session =
|
|
32
|
-
# Configure retry strategy
|
|
33
|
-
from requests.adapters import HTTPAdapter
|
|
34
|
-
from urllib3.util.retry import Retry
|
|
35
|
-
|
|
36
|
-
retry_strategy = Retry(
|
|
37
|
-
total=3,
|
|
38
|
-
backoff_factor=1,
|
|
39
|
-
status_forcelist=[429, 500, 502, 503, 504],
|
|
40
|
-
)
|
|
41
|
-
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
42
|
-
self.session.mount("https://", adapter)
|
|
43
|
-
self.session.mount("http://", adapter)
|
|
31
|
+
self.session = create_retry_session()
|
|
44
32
|
|
|
45
33
|
def close(self):
|
|
46
34
|
"""Close the session."""
|
|
47
35
|
self.session.close()
|
|
48
36
|
|
|
49
|
-
def __del__(self):
|
|
50
|
-
"""Cleanup on deletion."""
|
|
51
|
-
self.close()
|
|
52
|
-
|
|
53
37
|
def get_token_price(self, token_id: str, vs_currency: str = "eur") -> Optional[float]:
|
|
54
38
|
"""Get token price in specified currency.
|
|
55
39
|
|
|
@@ -115,9 +99,7 @@ class PriceService:
|
|
|
115
99
|
return float(data[token_id][vs_currency])
|
|
116
100
|
|
|
117
101
|
# If we got response but price not found, it's likely a wrong ID
|
|
118
|
-
logger.debug(
|
|
119
|
-
f"Price for {token_id} in {vs_currency} not found in response: {data}"
|
|
120
|
-
)
|
|
102
|
+
logger.debug(f"Price for {token_id} in {vs_currency} not found in response: {data}")
|
|
121
103
|
return None
|
|
122
104
|
|
|
123
105
|
except Exception as e:
|
iwa/core/rpc_monitor.py
CHANGED
iwa/core/services/balance.py
CHANGED
iwa/core/services/safe.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Safe service module."""
|
|
2
2
|
|
|
3
|
-
from typing import TYPE_CHECKING, List, Optional, Tuple
|
|
3
|
+
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
|
4
4
|
|
|
5
5
|
from loguru import logger
|
|
6
6
|
from safe_eth.eth import EthereumClient
|
|
@@ -35,6 +35,7 @@ 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] = {}
|
|
38
39
|
|
|
39
40
|
def create_safe(
|
|
40
41
|
self,
|
|
@@ -102,7 +103,12 @@ class SafeService:
|
|
|
102
103
|
|
|
103
104
|
# Use ChainInterface which has proper RPC rotation and parsing
|
|
104
105
|
chain_interface = ChainInterfaces().get(chain_name)
|
|
105
|
-
|
|
106
|
+
rpc_url = chain_interface.current_rpc
|
|
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]
|
|
106
112
|
|
|
107
113
|
def _deploy_safe_contract(
|
|
108
114
|
self,
|