iwa 0.0.0__py3-none-any.whl → 0.0.1a2__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 (191) hide show
  1. conftest.py +22 -0
  2. iwa/__init__.py +1 -0
  3. iwa/__main__.py +6 -0
  4. iwa/core/__init__.py +1 -0
  5. iwa/core/chain/__init__.py +68 -0
  6. iwa/core/chain/errors.py +47 -0
  7. iwa/core/chain/interface.py +514 -0
  8. iwa/core/chain/manager.py +38 -0
  9. iwa/core/chain/models.py +128 -0
  10. iwa/core/chain/rate_limiter.py +193 -0
  11. iwa/core/cli.py +210 -0
  12. iwa/core/constants.py +28 -0
  13. iwa/core/contracts/__init__.py +1 -0
  14. iwa/core/contracts/contract.py +297 -0
  15. iwa/core/contracts/erc20.py +79 -0
  16. iwa/core/contracts/multisend.py +71 -0
  17. iwa/core/db.py +317 -0
  18. iwa/core/keys.py +361 -0
  19. iwa/core/mnemonic.py +385 -0
  20. iwa/core/models.py +344 -0
  21. iwa/core/monitor.py +209 -0
  22. iwa/core/plugins.py +45 -0
  23. iwa/core/pricing.py +91 -0
  24. iwa/core/services/__init__.py +17 -0
  25. iwa/core/services/account.py +57 -0
  26. iwa/core/services/balance.py +113 -0
  27. iwa/core/services/plugin.py +88 -0
  28. iwa/core/services/safe.py +392 -0
  29. iwa/core/services/transaction.py +172 -0
  30. iwa/core/services/transfer/__init__.py +166 -0
  31. iwa/core/services/transfer/base.py +260 -0
  32. iwa/core/services/transfer/erc20.py +247 -0
  33. iwa/core/services/transfer/multisend.py +386 -0
  34. iwa/core/services/transfer/native.py +262 -0
  35. iwa/core/services/transfer/swap.py +326 -0
  36. iwa/core/settings.py +95 -0
  37. iwa/core/tables.py +60 -0
  38. iwa/core/test.py +27 -0
  39. iwa/core/tests/test_wallet.py +255 -0
  40. iwa/core/types.py +59 -0
  41. iwa/core/ui.py +99 -0
  42. iwa/core/utils.py +59 -0
  43. iwa/core/wallet.py +380 -0
  44. iwa/plugins/__init__.py +1 -0
  45. iwa/plugins/gnosis/__init__.py +5 -0
  46. iwa/plugins/gnosis/cow/__init__.py +6 -0
  47. iwa/plugins/gnosis/cow/quotes.py +148 -0
  48. iwa/plugins/gnosis/cow/swap.py +403 -0
  49. iwa/plugins/gnosis/cow/types.py +20 -0
  50. iwa/plugins/gnosis/cow_utils.py +44 -0
  51. iwa/plugins/gnosis/plugin.py +68 -0
  52. iwa/plugins/gnosis/safe.py +157 -0
  53. iwa/plugins/gnosis/tests/test_cow.py +227 -0
  54. iwa/plugins/gnosis/tests/test_safe.py +100 -0
  55. iwa/plugins/olas/__init__.py +5 -0
  56. iwa/plugins/olas/constants.py +106 -0
  57. iwa/plugins/olas/contracts/activity_checker.py +93 -0
  58. iwa/plugins/olas/contracts/base.py +10 -0
  59. iwa/plugins/olas/contracts/mech.py +49 -0
  60. iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
  61. iwa/plugins/olas/contracts/service.py +215 -0
  62. iwa/plugins/olas/contracts/staking.py +403 -0
  63. iwa/plugins/olas/importer.py +736 -0
  64. iwa/plugins/olas/mech_reference.py +135 -0
  65. iwa/plugins/olas/models.py +110 -0
  66. iwa/plugins/olas/plugin.py +243 -0
  67. iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
  68. iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
  69. iwa/plugins/olas/service_manager/__init__.py +60 -0
  70. iwa/plugins/olas/service_manager/base.py +113 -0
  71. iwa/plugins/olas/service_manager/drain.py +336 -0
  72. iwa/plugins/olas/service_manager/lifecycle.py +839 -0
  73. iwa/plugins/olas/service_manager/mech.py +322 -0
  74. iwa/plugins/olas/service_manager/staking.py +530 -0
  75. iwa/plugins/olas/tests/conftest.py +30 -0
  76. iwa/plugins/olas/tests/test_importer.py +128 -0
  77. iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
  78. iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
  79. iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
  80. iwa/plugins/olas/tests/test_olas_integration.py +561 -0
  81. iwa/plugins/olas/tests/test_olas_models.py +144 -0
  82. iwa/plugins/olas/tests/test_olas_view.py +258 -0
  83. iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
  84. iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
  85. iwa/plugins/olas/tests/test_plugin.py +70 -0
  86. iwa/plugins/olas/tests/test_plugin_full.py +212 -0
  87. iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
  88. iwa/plugins/olas/tests/test_service_manager.py +1065 -0
  89. iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
  90. iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
  91. iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
  92. iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
  93. iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
  94. iwa/plugins/olas/tests/test_service_staking.py +342 -0
  95. iwa/plugins/olas/tests/test_staking_integration.py +269 -0
  96. iwa/plugins/olas/tests/test_staking_validation.py +109 -0
  97. iwa/plugins/olas/tui/__init__.py +1 -0
  98. iwa/plugins/olas/tui/olas_view.py +952 -0
  99. iwa/tools/check_profile.py +67 -0
  100. iwa/tools/release.py +111 -0
  101. iwa/tools/reset_env.py +111 -0
  102. iwa/tools/reset_tenderly.py +362 -0
  103. iwa/tools/restore_backup.py +82 -0
  104. iwa/tui/__init__.py +1 -0
  105. iwa/tui/app.py +174 -0
  106. iwa/tui/modals/__init__.py +5 -0
  107. iwa/tui/modals/base.py +406 -0
  108. iwa/tui/rpc.py +63 -0
  109. iwa/tui/screens/__init__.py +1 -0
  110. iwa/tui/screens/wallets.py +749 -0
  111. iwa/tui/tests/test_app.py +125 -0
  112. iwa/tui/tests/test_rpc.py +139 -0
  113. iwa/tui/tests/test_wallets_refactor.py +30 -0
  114. iwa/tui/tests/test_widgets.py +123 -0
  115. iwa/tui/widgets/__init__.py +5 -0
  116. iwa/tui/widgets/base.py +100 -0
  117. iwa/tui/workers.py +42 -0
  118. iwa/web/dependencies.py +76 -0
  119. iwa/web/models.py +76 -0
  120. iwa/web/routers/accounts.py +115 -0
  121. iwa/web/routers/olas/__init__.py +24 -0
  122. iwa/web/routers/olas/admin.py +169 -0
  123. iwa/web/routers/olas/funding.py +135 -0
  124. iwa/web/routers/olas/general.py +29 -0
  125. iwa/web/routers/olas/services.py +378 -0
  126. iwa/web/routers/olas/staking.py +341 -0
  127. iwa/web/routers/state.py +65 -0
  128. iwa/web/routers/swap.py +617 -0
  129. iwa/web/routers/transactions.py +153 -0
  130. iwa/web/server.py +155 -0
  131. iwa/web/tests/test_web_endpoints.py +713 -0
  132. iwa/web/tests/test_web_olas.py +430 -0
  133. iwa/web/tests/test_web_swap.py +103 -0
  134. iwa-0.0.1a2.dist-info/METADATA +234 -0
  135. iwa-0.0.1a2.dist-info/RECORD +186 -0
  136. iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
  137. iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
  138. iwa-0.0.1a2.dist-info/top_level.txt +4 -0
  139. tests/legacy_cow.py +248 -0
  140. tests/legacy_safe.py +93 -0
  141. tests/legacy_transaction_retry_logic.py +51 -0
  142. tests/legacy_tui.py +440 -0
  143. tests/legacy_wallets_screen.py +554 -0
  144. tests/legacy_web.py +243 -0
  145. tests/test_account_service.py +120 -0
  146. tests/test_balance_service.py +186 -0
  147. tests/test_chain.py +490 -0
  148. tests/test_chain_interface.py +210 -0
  149. tests/test_cli.py +139 -0
  150. tests/test_contract.py +195 -0
  151. tests/test_db.py +180 -0
  152. tests/test_drain_coverage.py +174 -0
  153. tests/test_erc20.py +95 -0
  154. tests/test_gnosis_plugin.py +111 -0
  155. tests/test_keys.py +449 -0
  156. tests/test_legacy_wallet.py +1285 -0
  157. tests/test_main.py +13 -0
  158. tests/test_mnemonic.py +217 -0
  159. tests/test_modals.py +109 -0
  160. tests/test_models.py +213 -0
  161. tests/test_monitor.py +202 -0
  162. tests/test_multisend.py +84 -0
  163. tests/test_plugin_service.py +119 -0
  164. tests/test_pricing.py +143 -0
  165. tests/test_rate_limiter.py +199 -0
  166. tests/test_reset_tenderly.py +202 -0
  167. tests/test_rpc_view.py +73 -0
  168. tests/test_safe_coverage.py +139 -0
  169. tests/test_safe_service.py +168 -0
  170. tests/test_service_manager_integration.py +61 -0
  171. tests/test_service_manager_structure.py +31 -0
  172. tests/test_service_transaction.py +176 -0
  173. tests/test_staking_router.py +71 -0
  174. tests/test_staking_simple.py +31 -0
  175. tests/test_tables.py +76 -0
  176. tests/test_transaction_service.py +161 -0
  177. tests/test_transfer_multisend.py +179 -0
  178. tests/test_transfer_native.py +220 -0
  179. tests/test_transfer_security.py +93 -0
  180. tests/test_transfer_structure.py +37 -0
  181. tests/test_transfer_swap_unit.py +155 -0
  182. tests/test_ui_coverage.py +66 -0
  183. tests/test_utils.py +53 -0
  184. tests/test_workers.py +91 -0
  185. tools/verify_drain.py +183 -0
  186. __init__.py +0 -2
  187. hello.py +0 -6
  188. iwa-0.0.0.dist-info/METADATA +0 -10
  189. iwa-0.0.0.dist-info/RECORD +0 -6
  190. iwa-0.0.0.dist-info/top_level.txt +0 -2
  191. {iwa-0.0.0.dist-info → iwa-0.0.1a2.dist-info}/WHEEL +0 -0
@@ -0,0 +1,17 @@
1
+ """Core services package."""
2
+
3
+ from iwa.core.services.account import AccountService
4
+ from iwa.core.services.balance import BalanceService
5
+ from iwa.core.services.plugin import PluginService
6
+ from iwa.core.services.safe import SafeService
7
+ from iwa.core.services.transaction import TransactionService
8
+ from iwa.core.services.transfer import TransferService
9
+
10
+ __all__ = [
11
+ "AccountService",
12
+ "BalanceService",
13
+ "PluginService",
14
+ "SafeService",
15
+ "TransactionService",
16
+ "TransferService",
17
+ ]
@@ -0,0 +1,57 @@
1
+ """Account service module."""
2
+
3
+ from typing import TYPE_CHECKING, Dict, Optional, Union
4
+
5
+ from loguru import logger
6
+
7
+ from iwa.core.chain import SupportedChain
8
+ from iwa.core.constants import NATIVE_CURRENCY_ADDRESS
9
+ from iwa.core.models import EthereumAddress, StoredSafeAccount
10
+
11
+ if TYPE_CHECKING:
12
+ from iwa.core.keys import EncryptedAccount, KeyStorage
13
+
14
+
15
+ class AccountService:
16
+ """Service for account resolution and management."""
17
+
18
+ def __init__(self, key_storage: "KeyStorage"):
19
+ """Initialize AccountService."""
20
+ self.key_storage = key_storage
21
+
22
+ @property
23
+ def master_account(self) -> Optional[StoredSafeAccount]:
24
+ """Get master account."""
25
+ return self.key_storage.master_account
26
+
27
+ def get_token_address(
28
+ self, token_address_or_name: str, chain: SupportedChain
29
+ ) -> Optional[EthereumAddress]:
30
+ """Get token address from address or name."""
31
+ if token_address_or_name == "native":
32
+ return EthereumAddress(NATIVE_CURRENCY_ADDRESS)
33
+
34
+ try:
35
+ return EthereumAddress(token_address_or_name)
36
+ except ValueError:
37
+ token_address = chain.get_token_address(token_address_or_name)
38
+ if not token_address:
39
+ logger.error(f"Token '{token_address_or_name}' not found on chain '{chain.name}'.")
40
+ return None
41
+ return token_address
42
+
43
+ def resolve_account(
44
+ self, address_or_tag: str
45
+ ) -> Optional[Union[StoredSafeAccount, "EncryptedAccount"]]:
46
+ """Resolve account from address or tag."""
47
+ return self.key_storage.get_account(address_or_tag)
48
+
49
+ def get_tag_by_address(self, address: str) -> Optional[str]:
50
+ """Get tag for a given address."""
51
+ return self.key_storage.get_tag_by_address(address)
52
+
53
+ def get_account_data(
54
+ self,
55
+ ) -> Dict[EthereumAddress, Union[StoredSafeAccount, "EncryptedAccount"]]:
56
+ """Get all accounts data."""
57
+ return self.key_storage.accounts
@@ -0,0 +1,113 @@
1
+ """Balance service module."""
2
+
3
+ import time
4
+ from typing import TYPE_CHECKING, Optional, Union
5
+
6
+ from loguru import logger
7
+ from web3.types import Wei
8
+
9
+ from iwa.core.chain import ChainInterfaces
10
+ from iwa.core.contracts.erc20 import ERC20Contract
11
+
12
+ if TYPE_CHECKING:
13
+ from iwa.core.keys import KeyStorage
14
+ from iwa.core.services_pkg.account import AccountService
15
+ from iwa.core.wallet import Wallet
16
+
17
+
18
+ class BalanceService:
19
+ """Service for fetching native and ERC20 balances."""
20
+
21
+ def __init__(
22
+ self,
23
+ wallet_or_key_storage: Union["Wallet", "KeyStorage"],
24
+ account_service: "AccountService",
25
+ ):
26
+ """Initialize BalanceService."""
27
+ self.key_storage = (
28
+ wallet_or_key_storage.key_storage
29
+ if hasattr(wallet_or_key_storage, "key_storage")
30
+ else wallet_or_key_storage
31
+ )
32
+ self.account_service = account_service
33
+
34
+ def get_native_balance_eth(
35
+ self, account_address_or_tag: str, chain_name: str = "gnosis"
36
+ ) -> Optional[float]:
37
+ """Get native currency balance in ETH."""
38
+ account = self.account_service.resolve_account(account_address_or_tag)
39
+ if not account:
40
+ # If not found, try to use as raw address
41
+ address = account_address_or_tag
42
+ else:
43
+ address = account.address
44
+
45
+ chain_interface = ChainInterfaces().get(chain_name)
46
+ return chain_interface.get_native_balance_eth(address)
47
+
48
+ def get_native_balance_wei(
49
+ self, account_address_or_tag: str, chain_name: str = "gnosis"
50
+ ) -> Optional[Wei]:
51
+ """Get native currency balance in WEI."""
52
+ account = self.account_service.resolve_account(account_address_or_tag)
53
+ if not account:
54
+ # If not found, try to use as raw address
55
+ address = account_address_or_tag
56
+ else:
57
+ address = account.address
58
+
59
+ chain_interface = ChainInterfaces().get(chain_name)
60
+ return chain_interface.get_native_balance_wei(address)
61
+
62
+ def get_erc20_balance_eth(
63
+ self, account_address_or_tag: str, token_address_or_name: str, chain_name: str = "gnosis"
64
+ ) -> Optional[float]:
65
+ """Get ERC20 token balance in ETH-like format."""
66
+ chain = ChainInterfaces().get(chain_name)
67
+ token_address = self.account_service.get_token_address(token_address_or_name, chain.chain)
68
+ if not token_address:
69
+ return None
70
+
71
+ account = self.account_service.resolve_account(account_address_or_tag)
72
+ if not account:
73
+ return None
74
+
75
+ contract = ERC20Contract(chain_name=chain_name, address=token_address)
76
+ return contract.balance_of_eth(account.address)
77
+
78
+ def get_erc20_balance_wei(
79
+ self, account_address_or_tag: str, token_address_or_name: str, chain_name: str = "gnosis"
80
+ ) -> Optional[Wei]:
81
+ """Get ERC20 token balance in WEI."""
82
+ chain = ChainInterfaces().get(chain_name)
83
+ token_address = self.account_service.get_token_address(token_address_or_name, chain.chain)
84
+ if not token_address:
85
+ return None
86
+
87
+ account = self.account_service.resolve_account(account_address_or_tag)
88
+ if not account:
89
+ return None
90
+
91
+ contract = ERC20Contract(chain_name=chain_name, address=token_address)
92
+ 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
@@ -0,0 +1,88 @@
1
+ """Plugin Service module."""
2
+
3
+ import importlib
4
+ import inspect
5
+ import pkgutil
6
+ from typing import Dict, List, Optional
7
+
8
+ from loguru import logger
9
+
10
+ from iwa.core.plugins import Plugin
11
+
12
+
13
+ class PluginService:
14
+ """Manages plugin discovery, loading, and lifecycle."""
15
+
16
+ def __init__(self, plugins_package: str = "iwa.plugins"):
17
+ """Initialize PluginService.
18
+
19
+ Args:
20
+ plugins_package: Python package path to search for plugins.
21
+
22
+ """
23
+ self.plugins_package = plugins_package
24
+ self.loaded_plugins: Dict[str, Plugin] = {}
25
+ self._load_plugins()
26
+
27
+ def _discover_plugins(self) -> List[str]:
28
+ """Discover available plugins in the plugins package."""
29
+ try:
30
+ package = importlib.import_module(self.plugins_package)
31
+ if not hasattr(package, "__path__"):
32
+ return []
33
+
34
+ return [name for _, name, is_pkg in pkgutil.iter_modules(package.__path__) if is_pkg]
35
+ except ImportError:
36
+ logger.warning(f"Could not import plugins package: {self.plugins_package}")
37
+ return []
38
+
39
+ def _load_plugins(self) -> None:
40
+ """Load all discovered plugins."""
41
+ from iwa.core.models import Config
42
+
43
+ plugin_names = self._discover_plugins()
44
+ config = Config()
45
+
46
+ for name in plugin_names:
47
+ if name in self.loaded_plugins:
48
+ continue
49
+
50
+ try:
51
+ module_name = f"{self.plugins_package}.{name}"
52
+ module = importlib.import_module(module_name)
53
+
54
+ # Find Plugin subclass
55
+ for _, obj in inspect.getmembers(module):
56
+ if inspect.isclass(obj) and issubclass(obj, Plugin) and obj is not Plugin:
57
+ try:
58
+ plugin_instance = obj()
59
+ # Verify unique name
60
+ if plugin_instance.name in self.loaded_plugins:
61
+ logger.warning(
62
+ f"Plugin name collision: {plugin_instance.name}. Skipping."
63
+ )
64
+ continue
65
+
66
+ # Register plugin's config model if it has one
67
+ if plugin_instance.config_model:
68
+ config.register_plugin_config(
69
+ plugin_instance.name, plugin_instance.config_model
70
+ )
71
+
72
+ self.loaded_plugins[plugin_instance.name] = plugin_instance
73
+ plugin_instance.on_load()
74
+ logger.info(f"Loaded plugin: {plugin_instance.name}")
75
+ except Exception as e:
76
+ logger.error(f"Failed to instantiate plugin {name}: {e}")
77
+
78
+ except Exception as e:
79
+ logger.error(f"Failed to load plugin module {name}: {e}")
80
+
81
+ def get_plugin(self, name: str) -> Optional[Plugin]:
82
+ """Get a loaded plugin by name."""
83
+ return self.loaded_plugins.get(name)
84
+
85
+ def get_all_plugins(self) -> Dict[str, Plugin]:
86
+ """Get all loaded plugins."""
87
+ # Return a copy to prevent modification
88
+ return self.loaded_plugins.copy()
@@ -0,0 +1,392 @@
1
+ """Safe service module."""
2
+
3
+ from typing import TYPE_CHECKING, List, Optional, Tuple
4
+
5
+ from loguru import logger
6
+ from safe_eth.eth import EthereumClient
7
+ from safe_eth.safe import Safe, SafeOperationEnum
8
+ from safe_eth.safe.proxy_factory import ProxyFactory
9
+ from safe_eth.safe.safe_tx import SafeTx
10
+
11
+ from iwa.core.constants import ZERO_ADDRESS
12
+ from iwa.core.db import log_transaction
13
+ from iwa.core.models import StoredSafeAccount
14
+ from iwa.core.settings import settings
15
+ from iwa.core.utils import (
16
+ get_safe_master_copy_address,
17
+ get_safe_proxy_factory_address,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from iwa.core.keys import EncryptedAccount, KeyStorage
22
+ from iwa.core.services.account import AccountService
23
+
24
+ # We need EncryptedAccount for checks at runtime
25
+ try:
26
+ from iwa.core.keys import EncryptedAccount
27
+ except ImportError:
28
+ # Circular import prevention if keys imports safe (it shouldn't)
29
+ pass
30
+
31
+
32
+ class SafeService:
33
+ """Service for Safe deployment and management."""
34
+
35
+ def __init__(self, key_storage: "KeyStorage", account_service: "AccountService"):
36
+ """Initialize SafeService."""
37
+ self.key_storage = key_storage
38
+ self.account_service = account_service
39
+
40
+ def create_safe(
41
+ self,
42
+ deployer_tag_or_address: str,
43
+ owner_tags_or_addresses: List[str],
44
+ threshold: int,
45
+ chain_name: str,
46
+ tag: Optional[str] = None,
47
+ salt_nonce: Optional[int] = None,
48
+ ) -> Tuple[StoredSafeAccount, str]:
49
+ """Deploy a new Safe."""
50
+ deployer_account = self._prepare_deployer_account(deployer_tag_or_address)
51
+ owner_addresses = self._resolve_owner_addresses(owner_tags_or_addresses)
52
+
53
+ ethereum_client = self._get_ethereum_client(chain_name)
54
+
55
+ contract_address, tx_hash = self._deploy_safe_contract(
56
+ deployer_account, owner_addresses, threshold, salt_nonce, ethereum_client
57
+ )
58
+
59
+ logger.info(
60
+ f"Safe {tag} [{contract_address}] deployed on {chain_name} on transaction: {tx_hash}"
61
+ )
62
+
63
+ self._log_safe_deployment(
64
+ deployer_account,
65
+ deployer_tag_or_address,
66
+ contract_address,
67
+ tx_hash,
68
+ chain_name,
69
+ ethereum_client,
70
+ tag,
71
+ )
72
+
73
+ safe_account = self._store_safe_account(
74
+ contract_address, chain_name, owner_addresses, threshold, tag
75
+ )
76
+
77
+ return safe_account, tx_hash
78
+
79
+ def _prepare_deployer_account(self, deployer_tag_or_address: str):
80
+ deployer_stored_account = self.key_storage.find_stored_account(deployer_tag_or_address)
81
+ if not deployer_stored_account or not isinstance(deployer_stored_account, EncryptedAccount):
82
+ raise ValueError(
83
+ f"Deployer account '{deployer_tag_or_address}' not found or is a Safe."
84
+ )
85
+ from eth_account import Account
86
+
87
+ deployer_private_key = self.key_storage._get_private_key(deployer_stored_account.address)
88
+ if not deployer_private_key:
89
+ raise ValueError("Deployer private key not available.")
90
+ return Account.from_key(deployer_private_key)
91
+
92
+ def _resolve_owner_addresses(self, owner_tags_or_addresses: List[str]) -> List[str]:
93
+ owner_addresses = []
94
+ for tag_or_address in owner_tags_or_addresses:
95
+ owner_stored_account = self.key_storage.find_stored_account(tag_or_address)
96
+ if not owner_stored_account:
97
+ raise ValueError(f"Owner account '{tag_or_address}' not found in wallet.")
98
+ owner_addresses.append(owner_stored_account.address)
99
+ return owner_addresses
100
+
101
+ def _get_ethereum_client(self, chain_name: str) -> EthereumClient:
102
+ rpc_secret = getattr(settings, f"{chain_name}_rpc")
103
+ return EthereumClient(rpc_secret.get_secret_value())
104
+
105
+ def _deploy_safe_contract(
106
+ self,
107
+ deployer_account,
108
+ owner_addresses: List[str],
109
+ threshold: int,
110
+ salt_nonce: Optional[int],
111
+ ethereum_client: EthereumClient,
112
+ ) -> Tuple[str, str]:
113
+ master_copy = get_safe_master_copy_address("1.4.1")
114
+ proxy_factory_address = get_safe_proxy_factory_address("1.4.1")
115
+
116
+ if salt_nonce is not None:
117
+ # Use ProxyFactory directly to enforce salt
118
+ proxy_factory = ProxyFactory(proxy_factory_address, ethereum_client)
119
+
120
+ # Encoded setup data
121
+ empty_safe = Safe(master_copy, ethereum_client)
122
+ setup_data = empty_safe.contract.functions.setup(
123
+ owner_addresses,
124
+ threshold,
125
+ str(ZERO_ADDRESS),
126
+ b"",
127
+ str(ZERO_ADDRESS),
128
+ str(ZERO_ADDRESS),
129
+ 0,
130
+ str(ZERO_ADDRESS),
131
+ ).build_transaction({"gas": 0, "gasPrice": 0})["data"]
132
+
133
+ gas_price = ethereum_client.w3.eth.gas_price
134
+ tx_sent = proxy_factory.deploy_proxy_contract_with_nonce(
135
+ deployer_account,
136
+ master_copy,
137
+ initializer=bytes.fromhex(setup_data[2:])
138
+ if setup_data.startswith("0x")
139
+ else bytes.fromhex(setup_data),
140
+ nonce=salt_nonce,
141
+ gas=5_000_000,
142
+ gas_price=gas_price,
143
+ )
144
+ return tx_sent.contract_address, tx_sent.tx_hash.hex()
145
+
146
+ else:
147
+ # Standard random salt via Safe.create
148
+ create_tx = Safe.create(
149
+ ethereum_client=ethereum_client,
150
+ deployer_account=deployer_account,
151
+ master_copy_address=master_copy,
152
+ owners=owner_addresses,
153
+ threshold=threshold,
154
+ proxy_factory_address=proxy_factory_address,
155
+ )
156
+ return create_tx.contract_address, create_tx.tx_hash.hex()
157
+
158
+ def _log_safe_deployment(
159
+ self,
160
+ deployer_account,
161
+ deployer_tag_or_address: str,
162
+ contract_address: str,
163
+ tx_hash: str,
164
+ chain_name: str,
165
+ ethereum_client: EthereumClient,
166
+ tag: Optional[str],
167
+ ):
168
+ # Resolve tag for logging
169
+ resolved_from_tag = self.account_service.get_tag_by_address(deployer_account.address)
170
+
171
+ # Get receipt and calculate gas info
172
+ gas_cost = None
173
+ gas_value_eur = None
174
+ try:
175
+ receipt = ethereum_client.w3.eth.get_transaction_receipt(tx_hash)
176
+ if receipt:
177
+ gas_used = receipt.get("gasUsed", 0)
178
+ effective_gas_price = receipt.get("effectiveGasPrice", 0)
179
+ gas_cost = gas_used * effective_gas_price
180
+
181
+ # Get native token price for gas value calculation
182
+ from iwa.core.pricing import PriceService
183
+
184
+ chain_coingecko_ids = {"gnosis": "dai", "ethereum": "ethereum", "base": "ethereum"}
185
+ coingecko_id = chain_coingecko_ids.get(chain_name, "ethereum")
186
+ price_service = PriceService()
187
+ native_price_eur = price_service.get_token_price(coingecko_id, "eur")
188
+
189
+ if native_price_eur and gas_cost > 0:
190
+ gas_cost_eth = gas_cost / 10**18
191
+ gas_value_eur = gas_cost_eth * native_price_eur
192
+ except Exception as e:
193
+ logger.warning(f"Could not calculate gas info for Safe deployment: {e}")
194
+
195
+ # Get native currency symbol for this chain
196
+ from iwa.core.chain import ChainInterfaces
197
+
198
+ chain_interface = ChainInterfaces().get(chain_name)
199
+ native_symbol = chain_interface.chain.native_currency
200
+
201
+ log_transaction(
202
+ tx_hash=tx_hash,
203
+ from_addr=deployer_account.address,
204
+ to_addr=contract_address,
205
+ token=native_symbol,
206
+ amount_wei=0,
207
+ chain=chain_name,
208
+ from_tag=resolved_from_tag or deployer_tag_or_address,
209
+ to_tag=tag,
210
+ gas_cost=gas_cost,
211
+ gas_value_eur=gas_value_eur,
212
+ tags=["safe-deployment"],
213
+ )
214
+
215
+ def _store_safe_account(
216
+ self,
217
+ contract_address: str,
218
+ chain_name: str,
219
+ owner_addresses: List[str],
220
+ threshold: int,
221
+ tag: Optional[str],
222
+ ) -> StoredSafeAccount:
223
+ # Check if already exists
224
+ accounts = self.key_storage.accounts
225
+ if contract_address in accounts and isinstance(
226
+ accounts[contract_address], StoredSafeAccount
227
+ ):
228
+ safe_account = accounts[contract_address]
229
+ if chain_name not in safe_account.chains:
230
+ safe_account.chains.append(chain_name)
231
+ else:
232
+ safe_account = StoredSafeAccount(
233
+ tag=tag or f"Safe {contract_address[:6]}",
234
+ address=contract_address,
235
+ chains=[chain_name],
236
+ threshold=threshold,
237
+ signers=owner_addresses,
238
+ )
239
+ accounts[contract_address] = safe_account
240
+
241
+ self.key_storage.save()
242
+ return safe_account
243
+
244
+ def redeploy_safes(self):
245
+ """Redeploy all safes to ensure they exist on all chains."""
246
+ for account in list(self.key_storage.accounts.values()):
247
+ if not isinstance(account, StoredSafeAccount):
248
+ continue
249
+
250
+ for chain in account.chains:
251
+ rpc_secret = getattr(settings, f"{chain}_rpc")
252
+ ethereum_client = EthereumClient(rpc_secret.get_secret_value())
253
+
254
+ code = ethereum_client.w3.eth.get_code(account.address)
255
+
256
+ if code and code != b"":
257
+ continue
258
+
259
+ self.key_storage.remove_account(account.address)
260
+
261
+ self.create_safe(
262
+ deployer_tag_or_address="master",
263
+ owner_tags_or_addresses=account.signers,
264
+ threshold=account.threshold,
265
+ chain_name=chain,
266
+ tag=account.tag,
267
+ )
268
+
269
+ def _get_signer_keys(self, safe_account: StoredSafeAccount) -> List[str]:
270
+ """Get signer private keys for a safe (INTERNAL USE ONLY).
271
+
272
+ This method is private and should never be called from outside SafeService.
273
+ Keys are used only within execute_safe_transaction and cleared immediately after.
274
+ """
275
+ signer_pkeys = []
276
+ for signer_address in safe_account.signers:
277
+ pkey = self.key_storage._get_private_key(signer_address)
278
+ if pkey:
279
+ signer_pkeys.append(pkey)
280
+
281
+ if len(signer_pkeys) < safe_account.threshold:
282
+ raise ValueError(
283
+ "Not enough signer private keys in wallet to meet the Safe's threshold."
284
+ )
285
+
286
+ return signer_pkeys
287
+
288
+ def _sign_and_execute_safe_tx(self, safe_tx: SafeTx, signer_keys: List[str]) -> str:
289
+ """Sign and execute a SafeTx internally (INTERNAL USE ONLY).
290
+
291
+ This method handles the signing and execution of a Safe transaction,
292
+ keeping private keys internal to SafeService.
293
+
294
+ SECURITY: Keys are overwritten with zeros and cleared after use.
295
+ """
296
+ try:
297
+ # Sign with all available signers
298
+ for pk in signer_keys:
299
+ safe_tx.sign(pk)
300
+
301
+ # Verify the transaction will succeed
302
+ safe_tx.call()
303
+
304
+ # Execute using the first signer
305
+ safe_tx.execute(signer_keys[0])
306
+
307
+ return safe_tx.tx_hash.hex()
308
+ finally:
309
+ # SECURITY: Overwrite keys with zeros before clearing (best effort)
310
+ for i in range(len(signer_keys)):
311
+ signer_keys[i] = "0" * len(signer_keys[i]) if signer_keys[i] else ""
312
+ signer_keys.clear()
313
+
314
+ def execute_safe_transaction(
315
+ self,
316
+ safe_address_or_tag: str,
317
+ to: str,
318
+ value: int,
319
+ chain_name: str,
320
+ data: str = "",
321
+ operation: int = SafeOperationEnum.CALL.value,
322
+ ) -> str:
323
+ """Execute a Safe transaction with internal signing.
324
+
325
+ This is the preferred method for executing Safe transactions as it
326
+ handles all signing internally without exposing private keys.
327
+
328
+ Args:
329
+ safe_address_or_tag: The Safe account address or tag
330
+ to: Destination address
331
+ value: Amount in wei
332
+ chain_name: Chain name (e.g., 'gnosis')
333
+ data: Transaction data (hex string or empty)
334
+ operation: Safe operation type (CALL or DELEGATE_CALL)
335
+
336
+ Returns:
337
+ Transaction hash as hex string
338
+
339
+ """
340
+ from iwa.plugins.gnosis.safe import SafeMultisig
341
+
342
+ safe_account = self.key_storage.find_stored_account(safe_address_or_tag)
343
+ if not safe_account or not isinstance(safe_account, StoredSafeAccount):
344
+ raise ValueError(f"Safe account '{safe_address_or_tag}' not found.")
345
+
346
+ safe = SafeMultisig(safe_account, chain_name)
347
+ safe_tx = safe.build_tx(
348
+ to=to,
349
+ value=value,
350
+ data=data,
351
+ operation=operation,
352
+ )
353
+
354
+ # Get signer keys, execute, and immediately clear
355
+ signer_keys = self._get_signer_keys(safe_account)
356
+ try:
357
+ tx_hash = self._sign_and_execute_safe_tx(safe_tx, signer_keys)
358
+ logger.info(f"Safe transaction executed. Tx Hash: {tx_hash}")
359
+ return tx_hash
360
+ finally:
361
+ # Clear keys from memory (best effort)
362
+ for i in range(len(signer_keys)):
363
+ signer_keys[i] = None
364
+ del signer_keys
365
+
366
+ def get_sign_and_execute_callback(self, safe_address_or_tag: str):
367
+ """Get a callback function that signs and executes a SafeTx.
368
+
369
+ This method returns a callback that can be passed to SafeMultisig.send_tx().
370
+ The callback handles all signing internally.
371
+
372
+ Args:
373
+ safe_address_or_tag: The Safe account address or tag
374
+
375
+ Returns:
376
+ A callable that takes a SafeTx and returns the transaction hash
377
+
378
+ """
379
+ safe_account = self.key_storage.find_stored_account(safe_address_or_tag)
380
+ if not safe_account or not isinstance(safe_account, StoredSafeAccount):
381
+ raise ValueError(f"Safe account '{safe_address_or_tag}' not found.")
382
+
383
+ def _sign_and_execute(safe_tx: SafeTx) -> str:
384
+ signer_keys = self._get_signer_keys(safe_account)
385
+ try:
386
+ return self._sign_and_execute_safe_tx(safe_tx, signer_keys)
387
+ finally:
388
+ for i in range(len(signer_keys)):
389
+ signer_keys[i] = None
390
+ del signer_keys
391
+
392
+ return _sign_and_execute