iwa 0.0.33__py3-none-any.whl → 0.0.59__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. iwa/core/chain/interface.py +130 -11
  2. iwa/core/chain/models.py +15 -3
  3. iwa/core/chain/rate_limiter.py +48 -12
  4. iwa/core/chainlist.py +15 -10
  5. iwa/core/cli.py +4 -1
  6. iwa/core/contracts/cache.py +1 -1
  7. iwa/core/contracts/contract.py +1 -0
  8. iwa/core/contracts/decoder.py +10 -4
  9. iwa/core/http.py +31 -0
  10. iwa/core/ipfs.py +21 -7
  11. iwa/core/keys.py +65 -15
  12. iwa/core/models.py +58 -13
  13. iwa/core/pricing.py +10 -6
  14. iwa/core/rpc_monitor.py +1 -0
  15. iwa/core/secrets.py +27 -0
  16. iwa/core/services/account.py +1 -1
  17. iwa/core/services/balance.py +0 -23
  18. iwa/core/services/safe.py +72 -45
  19. iwa/core/services/safe_executor.py +350 -0
  20. iwa/core/services/transaction.py +43 -13
  21. iwa/core/services/transfer/erc20.py +14 -3
  22. iwa/core/services/transfer/native.py +14 -31
  23. iwa/core/services/transfer/swap.py +1 -0
  24. iwa/core/tests/test_gnosis_fee.py +91 -0
  25. iwa/core/tests/test_ipfs.py +85 -0
  26. iwa/core/tests/test_pricing.py +65 -0
  27. iwa/core/tests/test_regression_fixes.py +97 -0
  28. iwa/core/utils.py +2 -0
  29. iwa/core/wallet.py +6 -4
  30. iwa/plugins/gnosis/cow/quotes.py +2 -2
  31. iwa/plugins/gnosis/cow/swap.py +18 -32
  32. iwa/plugins/gnosis/tests/test_cow.py +19 -10
  33. iwa/plugins/olas/constants.py +15 -5
  34. iwa/plugins/olas/contracts/activity_checker.py +3 -3
  35. iwa/plugins/olas/contracts/staking.py +0 -1
  36. iwa/plugins/olas/events.py +15 -13
  37. iwa/plugins/olas/importer.py +29 -25
  38. iwa/plugins/olas/models.py +0 -3
  39. iwa/plugins/olas/plugin.py +16 -14
  40. iwa/plugins/olas/service_manager/drain.py +16 -9
  41. iwa/plugins/olas/service_manager/lifecycle.py +23 -12
  42. iwa/plugins/olas/service_manager/staking.py +15 -10
  43. iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
  44. iwa/plugins/olas/tests/test_olas_archiving.py +83 -0
  45. iwa/plugins/olas/tests/test_olas_integration.py +49 -29
  46. iwa/plugins/olas/tests/test_olas_view.py +5 -1
  47. iwa/plugins/olas/tests/test_service_manager.py +15 -17
  48. iwa/plugins/olas/tests/test_service_manager_errors.py +6 -5
  49. iwa/plugins/olas/tests/test_service_manager_flows.py +7 -6
  50. iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
  51. iwa/plugins/olas/tests/test_service_staking.py +64 -38
  52. iwa/tools/drain_accounts.py +61 -0
  53. iwa/tools/list_contracts.py +2 -0
  54. iwa/tools/reset_env.py +2 -1
  55. iwa/tools/test_chainlist.py +5 -1
  56. iwa/tui/screens/wallets.py +2 -4
  57. iwa/web/routers/accounts.py +1 -1
  58. iwa/web/routers/olas/services.py +10 -5
  59. iwa/web/static/app.js +21 -9
  60. iwa/web/static/style.css +4 -0
  61. iwa/web/tests/test_web_endpoints.py +2 -2
  62. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/METADATA +6 -3
  63. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/RECORD +82 -71
  64. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/WHEEL +1 -1
  65. tests/test_balance_service.py +0 -43
  66. tests/test_chain.py +13 -5
  67. tests/test_cli.py +2 -2
  68. tests/test_drain_coverage.py +12 -6
  69. tests/test_keys.py +23 -23
  70. tests/test_rate_limiter.py +2 -2
  71. tests/test_rate_limiter_retry.py +103 -0
  72. tests/test_rpc_efficiency.py +4 -1
  73. tests/test_rpc_rate_limit.py +34 -0
  74. tests/test_rpc_rotation.py +59 -11
  75. tests/test_safe_coverage.py +37 -23
  76. tests/test_safe_executor.py +361 -0
  77. tests/test_safe_integration.py +153 -0
  78. tests/test_safe_service.py +1 -1
  79. tests/test_transfer_swap_unit.py +5 -1
  80. tests/test_pricing.py +0 -160
  81. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/entry_points.txt +0 -0
  82. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/licenses/LICENSE +0 -0
  83. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/top_level.txt +0 -0
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.create_account("master")
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.get_account("master")
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.dump(self.model_dump(), f, indent=4)
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
- # Enforce read/write only for the owner
246
- os.chmod(self._path, 0o600)
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.accounts[encrypted_acct.address] = encrypted_acct
332
- self.save()
333
-
346
+ self.register_account(encrypted_acct)
334
347
  return encrypted_acct, mnemonic_str
335
348
 
336
- def create_account(self, tag: str) -> EncryptedAccount:
337
- """Create account. Master is derived from mnemonic, others are random."""
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.accounts[acct.address] = encrypted
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
- yaml.safe_dump(self.model_dump(), f, sort_keys=False, allow_unicode=True)
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 = yaml.safe_load(f)
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
- import yaml
227
-
260
+ ryaml = YAML()
228
261
  with CONFIG_PATH.open("r", encoding="utf-8") as f:
229
- data = yaml.safe_load(f) or {}
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
- yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True, default_flow_style=False)
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
- response = requests.get(url, params=params, headers=headers, timeout=10)
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 = requests.get(url, params=params, headers=headers, timeout=10)
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
@@ -1,4 +1,5 @@
1
1
  """RPC Monitor for tracking API usage."""
2
+
2
3
  import threading
3
4
  from collections import defaultdict
4
5
  from typing import Dict
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()
@@ -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
 
@@ -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
- return EthereumClient(chain_interface.current_rpc)
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
- accounts = self.key_storage.accounts
227
- if contract_address in accounts and isinstance(
228
- accounts[contract_address], StoredSafeAccount
229
- ):
230
- safe_account = accounts[contract_address]
231
- if chain_name not in safe_account.chains:
232
- safe_account.chains.append(chain_name)
233
- else:
234
- safe_account = StoredSafeAccount(
235
- tag=tag or f"Safe {contract_address[:6]}",
236
- address=contract_address,
237
- chains=[chain_name],
238
- threshold=threshold,
239
- signers=owner_addresses,
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
- self.key_storage.save()
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(self, safe_tx: SafeTx, signer_keys: List[str]) -> str:
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
- safe_tx.sign(pk)
321
+ if pk:
322
+ safe_tx.sign(pk)
305
323
 
306
- # Verify the transaction will succeed
307
- safe_tx.call()
324
+ chain_interface = ChainInterfaces().get(chain_name)
325
+ executor = SafeTransactionExecutor(chain_interface)
308
326
 
309
- # Execute using the first signer
310
- safe_tx.execute(signer_keys[0])
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
- signer_keys[i] = "0" * len(signer_keys[i]) if signer_keys[i] else ""
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
- try:
362
- tx_hash = self._sign_and_execute_safe_tx(safe_tx, signer_keys)
363
- logger.info(f"Safe transaction executed. Tx Hash: {tx_hash}")
364
- return tx_hash
365
- finally:
366
- # Clear keys from memory (best effort)
367
- for i in range(len(signer_keys)):
368
- signer_keys[i] = None
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
- try:
391
- return self._sign_and_execute_safe_tx(safe_tx, signer_keys)
392
- finally:
393
- for i in range(len(signer_keys)):
394
- signer_keys[i] = None
395
- del signer_keys
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