iwa 0.0.33__py3-none-any.whl → 0.0.59__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iwa/core/chain/interface.py +130 -11
- iwa/core/chain/models.py +15 -3
- iwa/core/chain/rate_limiter.py +48 -12
- iwa/core/chainlist.py +15 -10
- iwa/core/cli.py +4 -1
- 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 +21 -7
- iwa/core/keys.py +65 -15
- iwa/core/models.py +58 -13
- iwa/core/pricing.py +10 -6
- iwa/core/rpc_monitor.py +1 -0
- iwa/core/secrets.py +27 -0
- iwa/core/services/account.py +1 -1
- iwa/core/services/balance.py +0 -23
- iwa/core/services/safe.py +72 -45
- iwa/core/services/safe_executor.py +350 -0
- iwa/core/services/transaction.py +43 -13
- iwa/core/services/transfer/erc20.py +14 -3
- iwa/core/services/transfer/native.py +14 -31
- iwa/core/services/transfer/swap.py +1 -0
- iwa/core/tests/test_gnosis_fee.py +91 -0
- iwa/core/tests/test_ipfs.py +85 -0
- iwa/core/tests/test_pricing.py +65 -0
- iwa/core/tests/test_regression_fixes.py +97 -0
- iwa/core/utils.py +2 -0
- iwa/core/wallet.py +6 -4
- iwa/plugins/gnosis/cow/quotes.py +2 -2
- iwa/plugins/gnosis/cow/swap.py +18 -32
- iwa/plugins/gnosis/tests/test_cow.py +19 -10
- 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 +29 -25
- iwa/plugins/olas/models.py +0 -3
- iwa/plugins/olas/plugin.py +16 -14
- iwa/plugins/olas/service_manager/drain.py +16 -9
- iwa/plugins/olas/service_manager/lifecycle.py +23 -12
- iwa/plugins/olas/service_manager/staking.py +15 -10
- iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
- iwa/plugins/olas/tests/test_olas_archiving.py +83 -0
- iwa/plugins/olas/tests/test_olas_integration.py +49 -29
- iwa/plugins/olas/tests/test_olas_view.py +5 -1
- iwa/plugins/olas/tests/test_service_manager.py +15 -17
- iwa/plugins/olas/tests/test_service_manager_errors.py +6 -5
- iwa/plugins/olas/tests/test_service_manager_flows.py +7 -6
- iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
- iwa/plugins/olas/tests/test_service_staking.py +64 -38
- iwa/tools/drain_accounts.py +61 -0
- iwa/tools/list_contracts.py +2 -0
- iwa/tools/reset_env.py +2 -1
- iwa/tools/test_chainlist.py +5 -1
- iwa/tui/screens/wallets.py +2 -4
- iwa/web/routers/accounts.py +1 -1
- iwa/web/routers/olas/services.py +10 -5
- iwa/web/static/app.js +21 -9
- iwa/web/static/style.css +4 -0
- iwa/web/tests/test_web_endpoints.py +2 -2
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/METADATA +6 -3
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/RECORD +82 -71
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/WHEEL +1 -1
- tests/test_balance_service.py +0 -43
- tests/test_chain.py +13 -5
- tests/test_cli.py +2 -2
- tests/test_drain_coverage.py +12 -6
- tests/test_keys.py +23 -23
- tests/test_rate_limiter.py +2 -2
- tests/test_rate_limiter_retry.py +103 -0
- tests/test_rpc_efficiency.py +4 -1
- tests/test_rpc_rate_limit.py +34 -0
- tests/test_rpc_rotation.py +59 -11
- tests/test_safe_coverage.py +37 -23
- tests/test_safe_executor.py +361 -0
- tests/test_safe_integration.py +153 -0
- tests/test_safe_service.py +1 -1
- tests/test_transfer_swap_unit.py +5 -1
- tests/test_pricing.py +0 -160
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/top_level.txt +0 -0
iwa/core/keys.py
CHANGED
|
@@ -210,14 +210,14 @@ class KeyStorage(BaseModel):
|
|
|
210
210
|
if not self.get_address_by_tag("master"):
|
|
211
211
|
logger.info("Master account not found. Creating new 'master' account...")
|
|
212
212
|
try:
|
|
213
|
-
self.
|
|
213
|
+
self.generate_new_account("master")
|
|
214
214
|
except Exception as e:
|
|
215
215
|
logger.error(f"Failed to create master account: {e}")
|
|
216
216
|
|
|
217
217
|
@property
|
|
218
|
-
def master_account(self) -> EncryptedAccount:
|
|
218
|
+
def master_account(self) -> Optional[Union[EncryptedAccount, StoredSafeAccount]]:
|
|
219
219
|
"""Get the master account"""
|
|
220
|
-
master_account = self.
|
|
220
|
+
master_account = self.find_stored_account("master")
|
|
221
221
|
|
|
222
222
|
if not master_account:
|
|
223
223
|
return list(self.accounts.values())[0]
|
|
@@ -231,19 +231,34 @@ class KeyStorage(BaseModel):
|
|
|
231
231
|
# Use backup directory relative to wallet path (supports tests with tmp_path)
|
|
232
232
|
backup_dir = self._path.parent / "backup"
|
|
233
233
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
try:
|
|
235
|
+
os.chmod(backup_dir, 0o700)
|
|
236
|
+
except OSError as e:
|
|
237
|
+
logger.debug(f"Could not chmod backup dir (expected in some Docker setups): {e}")
|
|
234
238
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
235
239
|
backup_path = backup_dir / f"wallet.json.{timestamp}.bkp"
|
|
236
240
|
shutil.copy2(self._path, backup_path)
|
|
241
|
+
try:
|
|
242
|
+
os.chmod(backup_path, 0o600)
|
|
243
|
+
except OSError as e:
|
|
244
|
+
logger.debug(f"Could not chmod backup file: {e}")
|
|
237
245
|
logger.debug(f"Backed up wallet to {backup_path}")
|
|
238
246
|
|
|
239
247
|
# Ensure directory exists
|
|
240
248
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
241
249
|
|
|
242
250
|
with open(self._path, "w", encoding="utf-8") as f:
|
|
243
|
-
json
|
|
251
|
+
# Use mode='json' to ensure all types (EthereumAddress) are correctly serialized
|
|
252
|
+
json.dump(self.model_dump(mode="json"), f, indent=4)
|
|
253
|
+
f.flush()
|
|
254
|
+
os.fsync(f.fileno()) # Force write to disk (critical for Docker volumes)
|
|
244
255
|
|
|
245
|
-
|
|
246
|
-
|
|
256
|
+
try:
|
|
257
|
+
os.chmod(self._path, 0o600)
|
|
258
|
+
except OSError as e:
|
|
259
|
+
logger.debug(f"Could not chmod wallet file: {e}")
|
|
260
|
+
|
|
261
|
+
logger.info(f"[KeyStorage] Wallet saved to {self._path} ({len(self.accounts)} accounts)")
|
|
247
262
|
|
|
248
263
|
@staticmethod
|
|
249
264
|
def _encrypt_mnemonic(mnemonic: str, password: str) -> dict:
|
|
@@ -328,21 +343,20 @@ class KeyStorage(BaseModel):
|
|
|
328
343
|
encrypted_acct = EncryptedAccount.encrypt_private_key(
|
|
329
344
|
private_key_hex, self._password, "master"
|
|
330
345
|
)
|
|
331
|
-
self.
|
|
332
|
-
self.save()
|
|
333
|
-
|
|
346
|
+
self.register_account(encrypted_acct)
|
|
334
347
|
return encrypted_acct, mnemonic_str
|
|
335
348
|
|
|
336
|
-
def
|
|
337
|
-
"""
|
|
349
|
+
def generate_new_account(self, tag: str) -> EncryptedAccount:
|
|
350
|
+
"""Generate a brand new EOA account and register it with the given tag."""
|
|
351
|
+
# Note: register_account(tag) check is inside, but we handle 'master' logic here
|
|
338
352
|
tags = [acct.tag for acct in self.accounts.values()]
|
|
339
353
|
if not tags:
|
|
340
354
|
tag = "master" # First account is always master
|
|
341
|
-
if tag in tags:
|
|
342
|
-
raise ValueError(f"Tag '{tag}' already exists in wallet.")
|
|
343
355
|
|
|
344
356
|
# Master account: derive from mnemonic
|
|
345
357
|
if tag == "master":
|
|
358
|
+
if "master" in tags:
|
|
359
|
+
raise ValueError("Master account already exists in wallet.")
|
|
346
360
|
encrypted_acct, mnemonic = self._create_master_from_mnemonic()
|
|
347
361
|
self._pending_mnemonic = mnemonic # Store temporarily for display
|
|
348
362
|
return encrypted_acct
|
|
@@ -350,10 +364,28 @@ class KeyStorage(BaseModel):
|
|
|
350
364
|
# Non-master: random key as before
|
|
351
365
|
acct = Account.create()
|
|
352
366
|
encrypted = EncryptedAccount.encrypt_private_key(acct.key.hex(), self._password, tag)
|
|
353
|
-
self.
|
|
354
|
-
self.save()
|
|
367
|
+
self.register_account(encrypted)
|
|
355
368
|
return encrypted
|
|
356
369
|
|
|
370
|
+
def register_account(self, account: Union[EncryptedAccount, StoredSafeAccount]):
|
|
371
|
+
"""Register an account (EOA or Safe) in the storage with strict tag uniqueness checks."""
|
|
372
|
+
if not account.tag:
|
|
373
|
+
# Allow untagged accounts (rare but possible)
|
|
374
|
+
pass
|
|
375
|
+
else:
|
|
376
|
+
# Check for duplicate tags
|
|
377
|
+
for existing in self.accounts.values():
|
|
378
|
+
if existing.tag == account.tag and existing.address != account.address:
|
|
379
|
+
raise ValueError(
|
|
380
|
+
f"Tag '{account.tag}' is already used by address {existing.address}"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
self.accounts[account.address] = account
|
|
384
|
+
logger.info(
|
|
385
|
+
f"[KeyStorage] Registering account: tag='{account.tag}', address={account.address}"
|
|
386
|
+
)
|
|
387
|
+
self.save()
|
|
388
|
+
|
|
357
389
|
def get_pending_mnemonic(self) -> Optional[str]:
|
|
358
390
|
"""Get and clear the pending mnemonic (for one-time display).
|
|
359
391
|
|
|
@@ -419,6 +451,24 @@ class KeyStorage(BaseModel):
|
|
|
419
451
|
del self.accounts[account.address]
|
|
420
452
|
self.save()
|
|
421
453
|
|
|
454
|
+
def rename_account(self, address_or_tag: str, new_tag: str):
|
|
455
|
+
"""Rename an account's tag with uniqueness check."""
|
|
456
|
+
account = self.find_stored_account(address_or_tag)
|
|
457
|
+
if not account:
|
|
458
|
+
raise ValueError(f"Account '{address_or_tag}' not found.")
|
|
459
|
+
|
|
460
|
+
# Check if new tag is already used by a DIFFERENT account
|
|
461
|
+
for existing in self.accounts.values():
|
|
462
|
+
if existing.tag == new_tag and existing.address != account.address:
|
|
463
|
+
raise ValueError(f"Tag '{new_tag}' is already used by address {existing.address}")
|
|
464
|
+
|
|
465
|
+
old_tag = account.tag
|
|
466
|
+
account.tag = new_tag
|
|
467
|
+
logger.info(
|
|
468
|
+
f"[KeyStorage] Renaming account: '{old_tag}' -> '{new_tag}' (address={account.address})"
|
|
469
|
+
)
|
|
470
|
+
self.save()
|
|
471
|
+
|
|
422
472
|
def _get_private_key(self, address: str) -> Optional[str]:
|
|
423
473
|
"""Get private key (Internal)"""
|
|
424
474
|
account = self.accounts.get(EthereumAddress(address))
|
iwa/core/models.py
CHANGED
|
@@ -6,14 +6,26 @@ from typing import Dict, List, Optional, Type, TypeVar
|
|
|
6
6
|
|
|
7
7
|
import tomli
|
|
8
8
|
import tomli_w
|
|
9
|
-
import yaml
|
|
10
9
|
from pydantic import BaseModel, Field, PrivateAttr
|
|
11
10
|
from pydantic_core import core_schema
|
|
11
|
+
from ruamel.yaml import YAML
|
|
12
12
|
|
|
13
13
|
from iwa.core.types import EthereumAddress # noqa: F401 - re-exported for backwards compatibility
|
|
14
14
|
from iwa.core.utils import singleton
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _update_yaml_recursive(target: Dict, source: Dict) -> None:
|
|
18
|
+
"""Recursively update a ruamel.yaml CommentedMap with data from a dict.
|
|
19
|
+
|
|
20
|
+
This preserves comments and structure in the target map.
|
|
21
|
+
"""
|
|
22
|
+
for key, value in source.items():
|
|
23
|
+
if isinstance(value, dict) and key in target and isinstance(target[key], dict):
|
|
24
|
+
_update_yaml_recursive(target[key], value)
|
|
25
|
+
else:
|
|
26
|
+
target[key] = value
|
|
27
|
+
|
|
28
|
+
|
|
17
29
|
class EncryptedData(BaseModel):
|
|
18
30
|
"""Encrypted data structure with explicit KDF parameters."""
|
|
19
31
|
|
|
@@ -67,6 +79,12 @@ class CoreConfig(BaseModel):
|
|
|
67
79
|
)
|
|
68
80
|
tenderly_olas_funds: float = Field(default=100000.0, description="OLAS amount for vNet funding")
|
|
69
81
|
|
|
82
|
+
# Safe Transaction Retry System
|
|
83
|
+
safe_tx_max_retries: int = Field(default=6, description="Maximum retries for Safe transactions")
|
|
84
|
+
safe_tx_gas_buffer: float = Field(
|
|
85
|
+
default=1.5, description="Gas buffer multiplier for Safe transactions"
|
|
86
|
+
)
|
|
87
|
+
|
|
70
88
|
|
|
71
89
|
T = TypeVar("T", bound="StorableModel")
|
|
72
90
|
|
|
@@ -106,16 +124,31 @@ class StorableModel(BaseModel):
|
|
|
106
124
|
self._path = path
|
|
107
125
|
|
|
108
126
|
def save_yaml(self, path: Optional[Path] = None) -> None:
|
|
109
|
-
"""Save to YAML file"""
|
|
127
|
+
"""Save to YAML file preserving comments if file exists."""
|
|
110
128
|
if path is None:
|
|
111
129
|
if getattr(self, "_path", None) is None:
|
|
112
130
|
raise ValueError("Save path not specified and no previous path stored.")
|
|
113
131
|
path = self._path
|
|
114
132
|
|
|
115
133
|
path = path.with_suffix(".yaml")
|
|
134
|
+
ryaml = YAML()
|
|
135
|
+
ryaml.preserve_quotes = True
|
|
136
|
+
ryaml.indent(mapping=2, sequence=4, offset=2)
|
|
137
|
+
|
|
138
|
+
data = self.model_dump(mode="json")
|
|
139
|
+
|
|
140
|
+
if path.exists():
|
|
141
|
+
with path.open("r", encoding="utf-8") as f:
|
|
142
|
+
try:
|
|
143
|
+
target = ryaml.load(f) or {}
|
|
144
|
+
_update_yaml_recursive(target, data)
|
|
145
|
+
data = target
|
|
146
|
+
except Exception:
|
|
147
|
+
# Fallback to overwrite if load fails
|
|
148
|
+
pass
|
|
116
149
|
|
|
117
150
|
with path.open("w", encoding="utf-8") as f:
|
|
118
|
-
|
|
151
|
+
ryaml.dump(data, f)
|
|
119
152
|
self._storage_format = "yaml"
|
|
120
153
|
self._path = path
|
|
121
154
|
|
|
@@ -171,8 +204,9 @@ class StorableModel(BaseModel):
|
|
|
171
204
|
def load_yaml(cls: Type[T], path: str | Path) -> T:
|
|
172
205
|
"""Load from YAML file"""
|
|
173
206
|
path = Path(path)
|
|
207
|
+
ryaml = YAML()
|
|
174
208
|
with path.open("r", encoding="utf-8") as f:
|
|
175
|
-
data =
|
|
209
|
+
data = ryaml.load(f)
|
|
176
210
|
obj = cls(**data)
|
|
177
211
|
obj._storage_format = "yaml"
|
|
178
212
|
obj._path = path
|
|
@@ -223,10 +257,9 @@ class Config(StorableModel):
|
|
|
223
257
|
return
|
|
224
258
|
|
|
225
259
|
try:
|
|
226
|
-
|
|
227
|
-
|
|
260
|
+
ryaml = YAML()
|
|
228
261
|
with CONFIG_PATH.open("r", encoding="utf-8") as f:
|
|
229
|
-
data =
|
|
262
|
+
data = ryaml.load(f) or {}
|
|
230
263
|
|
|
231
264
|
# Load core config
|
|
232
265
|
if "core" in data:
|
|
@@ -270,25 +303,37 @@ class Config(StorableModel):
|
|
|
270
303
|
self.save_config()
|
|
271
304
|
|
|
272
305
|
def save_config(self) -> None:
|
|
273
|
-
"""Persist current config to config.yaml."""
|
|
274
|
-
import yaml
|
|
275
|
-
|
|
306
|
+
"""Persist current config to config.yaml preserving comments."""
|
|
276
307
|
from iwa.core.constants import CONFIG_PATH
|
|
277
308
|
|
|
278
309
|
data = {}
|
|
279
310
|
|
|
280
311
|
if self.core:
|
|
281
|
-
data["core"] = self.core.model_dump()
|
|
312
|
+
data["core"] = self.core.model_dump(mode="json")
|
|
282
313
|
|
|
283
314
|
data["plugins"] = {}
|
|
284
315
|
for plugin_name, plugin_config in self.plugins.items():
|
|
285
316
|
if isinstance(plugin_config, BaseModel):
|
|
286
|
-
data["plugins"][plugin_name] = plugin_config.model_dump()
|
|
317
|
+
data["plugins"][plugin_name] = plugin_config.model_dump(mode="json")
|
|
287
318
|
elif isinstance(plugin_config, dict):
|
|
288
319
|
data["plugins"][plugin_name] = plugin_config
|
|
289
320
|
|
|
321
|
+
ryaml = YAML()
|
|
322
|
+
ryaml.preserve_quotes = True
|
|
323
|
+
ryaml.indent(mapping=2, sequence=4, offset=2)
|
|
324
|
+
|
|
325
|
+
if CONFIG_PATH.exists():
|
|
326
|
+
with CONFIG_PATH.open("r", encoding="utf-8") as f:
|
|
327
|
+
try:
|
|
328
|
+
target = ryaml.load(f) or {}
|
|
329
|
+
_update_yaml_recursive(target, data)
|
|
330
|
+
data = target
|
|
331
|
+
except Exception:
|
|
332
|
+
# Fallback to overwrite if load fails
|
|
333
|
+
pass
|
|
334
|
+
|
|
290
335
|
with CONFIG_PATH.open("w", encoding="utf-8") as f:
|
|
291
|
-
|
|
336
|
+
ryaml.dump(data, f)
|
|
292
337
|
|
|
293
338
|
self._path = CONFIG_PATH
|
|
294
339
|
self._storage_format = "yaml"
|
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,6 +28,11 @@ class PriceService:
|
|
|
28
28
|
if self.secrets.coingecko_api_key
|
|
29
29
|
else None
|
|
30
30
|
)
|
|
31
|
+
self.session = create_retry_session()
|
|
32
|
+
|
|
33
|
+
def close(self):
|
|
34
|
+
"""Close the session."""
|
|
35
|
+
self.session.close()
|
|
31
36
|
|
|
32
37
|
def get_token_price(self, token_id: str, vs_currency: str = "eur") -> Optional[float]:
|
|
33
38
|
"""Get token price in specified currency.
|
|
@@ -66,7 +71,8 @@ class PriceService:
|
|
|
66
71
|
if self.api_key:
|
|
67
72
|
headers["x-cg-demo-api-key"] = self.api_key
|
|
68
73
|
|
|
69
|
-
|
|
74
|
+
# Use session instead of direct requests
|
|
75
|
+
response = self.session.get(url, params=params, headers=headers, timeout=10)
|
|
70
76
|
|
|
71
77
|
if response.status_code == 401 and self.api_key:
|
|
72
78
|
logger.warning("CoinGecko API key invalid (401). Retrying without key...")
|
|
@@ -74,7 +80,7 @@ class PriceService:
|
|
|
74
80
|
headers.pop("x-cg-demo-api-key", None)
|
|
75
81
|
# Re-run with base URL
|
|
76
82
|
url = f"{self.BASE_URL}/simple/price"
|
|
77
|
-
response =
|
|
83
|
+
response = self.session.get(url, params=params, headers=headers, timeout=10)
|
|
78
84
|
|
|
79
85
|
if response.status_code == 429:
|
|
80
86
|
logger.warning(
|
|
@@ -93,9 +99,7 @@ class PriceService:
|
|
|
93
99
|
return float(data[token_id][vs_currency])
|
|
94
100
|
|
|
95
101
|
# If we got response but price not found, it's likely a wrong ID
|
|
96
|
-
logger.debug(
|
|
97
|
-
f"Price for {token_id} in {vs_currency} not found in response: {data}"
|
|
98
|
-
)
|
|
102
|
+
logger.debug(f"Price for {token_id} in {vs_currency} not found in response: {data}")
|
|
99
103
|
return None
|
|
100
104
|
|
|
101
105
|
except Exception as e:
|
iwa/core/rpc_monitor.py
CHANGED
iwa/core/secrets.py
CHANGED
|
@@ -72,6 +72,33 @@ class Secrets(BaseSettings):
|
|
|
72
72
|
|
|
73
73
|
return self
|
|
74
74
|
|
|
75
|
+
@model_validator(mode="after")
|
|
76
|
+
def strip_quotes_from_secrets(self) -> "Secrets":
|
|
77
|
+
"""Strip leading/trailing quotes from SecretStr fields.
|
|
78
|
+
|
|
79
|
+
Docker env_file often preserves quotes (e.g. KEY="val" -> "val"),
|
|
80
|
+
which causes API authentication failures.
|
|
81
|
+
"""
|
|
82
|
+
for field_name, field_value in self:
|
|
83
|
+
if isinstance(field_value, SecretStr):
|
|
84
|
+
raw_value = field_value.get_secret_value()
|
|
85
|
+
# Check for matching quotes at start and end
|
|
86
|
+
if len(raw_value) >= 2 and (
|
|
87
|
+
(raw_value.startswith('"') and raw_value.endswith('"'))
|
|
88
|
+
or (raw_value.startswith("'") and raw_value.endswith("'"))
|
|
89
|
+
):
|
|
90
|
+
clean_value = raw_value[1:-1]
|
|
91
|
+
setattr(self, field_name, SecretStr(clean_value))
|
|
92
|
+
elif isinstance(field_value, str):
|
|
93
|
+
# Also strip quotes from plain string fields (like health_url)
|
|
94
|
+
if len(field_value) >= 2 and (
|
|
95
|
+
(field_value.startswith('"') and field_value.endswith('"'))
|
|
96
|
+
or (field_value.startswith("'") and field_value.endswith("'"))
|
|
97
|
+
):
|
|
98
|
+
clean_value = field_value[1:-1]
|
|
99
|
+
setattr(self, field_name, clean_value)
|
|
100
|
+
return self
|
|
101
|
+
|
|
75
102
|
|
|
76
103
|
# Global secrets instance
|
|
77
104
|
secrets = Secrets()
|
iwa/core/services/account.py
CHANGED
|
@@ -20,7 +20,7 @@ class AccountService:
|
|
|
20
20
|
self.key_storage = key_storage
|
|
21
21
|
|
|
22
22
|
@property
|
|
23
|
-
def master_account(self) -> Optional[StoredSafeAccount]:
|
|
23
|
+
def master_account(self) -> Optional[Union["EncryptedAccount", StoredSafeAccount]]:
|
|
24
24
|
"""Get master account."""
|
|
25
25
|
return self.key_storage.master_account
|
|
26
26
|
|
iwa/core/services/balance.py
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
"""Balance service module."""
|
|
2
2
|
|
|
3
|
-
import time
|
|
4
3
|
from typing import TYPE_CHECKING, Optional, Union
|
|
5
4
|
|
|
6
|
-
from loguru import logger
|
|
7
5
|
from web3.types import Wei
|
|
8
6
|
|
|
9
7
|
from iwa.core.chain import ChainInterfaces
|
|
@@ -90,24 +88,3 @@ class BalanceService:
|
|
|
90
88
|
|
|
91
89
|
contract = ERC20Contract(chain_name=chain_name, address=token_address)
|
|
92
90
|
return contract.balance_of_wei(account.address)
|
|
93
|
-
|
|
94
|
-
def get_erc20_balance_with_retry(
|
|
95
|
-
self,
|
|
96
|
-
account_address: str,
|
|
97
|
-
token_address_or_name: str,
|
|
98
|
-
chain_name: str = "gnosis",
|
|
99
|
-
retries: int = 3,
|
|
100
|
-
) -> Optional[float]:
|
|
101
|
-
"""Fetch balance with retry logic."""
|
|
102
|
-
for attempt in range(retries):
|
|
103
|
-
try:
|
|
104
|
-
return self.get_erc20_balance_eth(
|
|
105
|
-
account_address, token_address_or_name, chain_name
|
|
106
|
-
)
|
|
107
|
-
except Exception as e:
|
|
108
|
-
if attempt == retries - 1:
|
|
109
|
-
logger.error(
|
|
110
|
-
f"Failed to fetch balance for {token_address_or_name} after {retries} attempts: {e}"
|
|
111
|
-
)
|
|
112
|
-
time.sleep(1)
|
|
113
|
-
return None
|
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,
|
|
@@ -222,25 +228,25 @@ class SafeService:
|
|
|
222
228
|
threshold: int,
|
|
223
229
|
tag: Optional[str],
|
|
224
230
|
) -> StoredSafeAccount:
|
|
225
|
-
# Check if already exists
|
|
226
|
-
|
|
227
|
-
if
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
accounts[contract_address] = safe_account
|
|
231
|
+
# Check if already exists (by address)
|
|
232
|
+
existing = self.key_storage.find_stored_account(contract_address)
|
|
233
|
+
if existing and isinstance(existing, StoredSafeAccount):
|
|
234
|
+
if chain_name not in existing.chains:
|
|
235
|
+
existing.chains.append(chain_name)
|
|
236
|
+
self.key_storage.save()
|
|
237
|
+
return existing
|
|
238
|
+
|
|
239
|
+
# Create new Safe account object
|
|
240
|
+
safe_account = StoredSafeAccount(
|
|
241
|
+
tag=tag or f"Safe {contract_address[:6]}",
|
|
242
|
+
address=contract_address,
|
|
243
|
+
chains=[chain_name],
|
|
244
|
+
threshold=threshold,
|
|
245
|
+
signers=owner_addresses,
|
|
246
|
+
)
|
|
242
247
|
|
|
243
|
-
|
|
248
|
+
# Register via centralized method (enforces tag uniqueness)
|
|
249
|
+
self.key_storage.register_account(safe_account)
|
|
244
250
|
return safe_account
|
|
245
251
|
|
|
246
252
|
def redeploy_safes(self):
|
|
@@ -290,30 +296,51 @@ class SafeService:
|
|
|
290
296
|
|
|
291
297
|
return signer_pkeys
|
|
292
298
|
|
|
293
|
-
def _sign_and_execute_safe_tx(
|
|
299
|
+
def _sign_and_execute_safe_tx(
|
|
300
|
+
self,
|
|
301
|
+
safe_tx: SafeTx,
|
|
302
|
+
signer_keys: List[str],
|
|
303
|
+
chain_name: str,
|
|
304
|
+
safe_address: str,
|
|
305
|
+
) -> str:
|
|
294
306
|
"""Sign and execute a SafeTx internally (INTERNAL USE ONLY).
|
|
295
307
|
|
|
296
308
|
This method handles the signing and execution of a Safe transaction,
|
|
297
309
|
keeping private keys internal to SafeService.
|
|
298
310
|
|
|
311
|
+
Uses SafeTransactionExecutor for retry logic and gas handling.
|
|
312
|
+
|
|
299
313
|
SECURITY: Keys are overwritten with zeros and cleared after use.
|
|
300
314
|
"""
|
|
315
|
+
from iwa.core.chain import ChainInterfaces
|
|
316
|
+
from iwa.core.services.safe_executor import SafeTransactionExecutor
|
|
317
|
+
|
|
301
318
|
try:
|
|
302
|
-
# Sign with all available signers
|
|
319
|
+
# Sign with all available signers (local operation)
|
|
303
320
|
for pk in signer_keys:
|
|
304
|
-
|
|
321
|
+
if pk:
|
|
322
|
+
safe_tx.sign(pk)
|
|
305
323
|
|
|
306
|
-
|
|
307
|
-
|
|
324
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
325
|
+
executor = SafeTransactionExecutor(chain_interface)
|
|
308
326
|
|
|
309
|
-
|
|
310
|
-
|
|
327
|
+
success, tx_hash_or_error, receipt = executor.execute_with_retry(
|
|
328
|
+
safe_address=safe_address,
|
|
329
|
+
safe_tx=safe_tx,
|
|
330
|
+
signer_keys=signer_keys,
|
|
331
|
+
operation_name=f"safe_tx_{safe_address[:10]}",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if success:
|
|
335
|
+
return tx_hash_or_error
|
|
336
|
+
else:
|
|
337
|
+
raise ValueError(f"Safe transaction failed: {tx_hash_or_error}")
|
|
311
338
|
|
|
312
|
-
return f"0x{safe_tx.tx_hash.hex()}"
|
|
313
339
|
finally:
|
|
314
340
|
# SECURITY: Overwrite keys with zeros before clearing (best effort)
|
|
315
341
|
for i in range(len(signer_keys)):
|
|
316
|
-
|
|
342
|
+
if signer_keys[i]:
|
|
343
|
+
signer_keys[i] = "0" * len(signer_keys[i])
|
|
317
344
|
signer_keys.clear()
|
|
318
345
|
|
|
319
346
|
def execute_safe_transaction(
|
|
@@ -358,17 +385,16 @@ class SafeService:
|
|
|
358
385
|
|
|
359
386
|
# Get signer keys, execute, and immediately clear
|
|
360
387
|
signer_keys = self._get_signer_keys(safe_account)
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
del signer_keys
|
|
388
|
+
tx_hash = self._sign_and_execute_safe_tx(
|
|
389
|
+
safe_tx=safe_tx,
|
|
390
|
+
signer_keys=signer_keys,
|
|
391
|
+
chain_name=chain_name,
|
|
392
|
+
safe_address=safe_account.address,
|
|
393
|
+
)
|
|
394
|
+
logger.info(f"Safe transaction executed. Tx Hash: {tx_hash}")
|
|
395
|
+
return tx_hash
|
|
370
396
|
|
|
371
|
-
def get_sign_and_execute_callback(self, safe_address_or_tag: str):
|
|
397
|
+
def get_sign_and_execute_callback(self, safe_address_or_tag: str, chain_name: str):
|
|
372
398
|
"""Get a callback function that signs and executes a SafeTx.
|
|
373
399
|
|
|
374
400
|
This method returns a callback that can be passed to SafeMultisig.send_tx().
|
|
@@ -376,6 +402,7 @@ class SafeService:
|
|
|
376
402
|
|
|
377
403
|
Args:
|
|
378
404
|
safe_address_or_tag: The Safe account address or tag
|
|
405
|
+
chain_name: The chain name for context
|
|
379
406
|
|
|
380
407
|
Returns:
|
|
381
408
|
A callable that takes a SafeTx and returns the transaction hash
|
|
@@ -387,11 +414,11 @@ class SafeService:
|
|
|
387
414
|
|
|
388
415
|
def _sign_and_execute(safe_tx: SafeTx) -> str:
|
|
389
416
|
signer_keys = self._get_signer_keys(safe_account)
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
417
|
+
return self._sign_and_execute_safe_tx(
|
|
418
|
+
safe_tx=safe_tx,
|
|
419
|
+
signer_keys=signer_keys,
|
|
420
|
+
chain_name=chain_name,
|
|
421
|
+
safe_address=safe_account.address,
|
|
422
|
+
)
|
|
396
423
|
|
|
397
424
|
return _sign_and_execute
|