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,736 @@
1
+ """Olas service importer module.
2
+
3
+ Discover and import Olas services and their keys from external directories.
4
+ Supports two formats:
5
+ - .trader_runner (trader_alpha style)
6
+ - .operate (trader_xi style)
7
+ """
8
+
9
+ import json
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import List, Optional, Tuple
13
+
14
+ from eth_account import Account
15
+ from loguru import logger
16
+
17
+ from iwa.core.keys import EncryptedAccount, KeyStorage
18
+ from iwa.core.models import Config, StoredSafeAccount
19
+
20
+
21
+ @dataclass
22
+ class DiscoveredKey:
23
+ """A discovered Ethereum key."""
24
+
25
+ address: str
26
+ private_key: Optional[str] = None # Plaintext hex (None if still encrypted)
27
+ encrypted_keystore: Optional[dict] = None # Web3 v3 keystore format
28
+ source_file: Path = field(default_factory=Path)
29
+ role: str = "unknown" # "agent", "operator", "owner"
30
+ is_encrypted: bool = False
31
+
32
+ @property
33
+ def is_decrypted(self) -> bool:
34
+ """Check if we have the plaintext private key."""
35
+ return self.private_key is not None
36
+
37
+
38
+ @dataclass
39
+ class DiscoveredSafe:
40
+ """A discovered Safe multisig."""
41
+
42
+ address: str
43
+ chain_name: str = "gnosis"
44
+ signers: List[str] = field(default_factory=list)
45
+
46
+
47
+ @dataclass
48
+ class DiscoveredService:
49
+ """A discovered Olas service."""
50
+
51
+ service_id: Optional[int] = None
52
+ chain_name: str = "gnosis"
53
+ safe_address: Optional[str] = None
54
+ keys: List[DiscoveredKey] = field(default_factory=list)
55
+ source_folder: Path = field(default_factory=Path)
56
+ format: str = "unknown" # "trader_runner" or "operate"
57
+ service_name: Optional[str] = None
58
+
59
+ @property
60
+ def agent_key(self) -> Optional[DiscoveredKey]:
61
+ """Get the agent key if present."""
62
+ for key in self.keys:
63
+ if key.role == "agent":
64
+ return key
65
+ return None
66
+
67
+ @property
68
+ def operator_key(self) -> Optional[DiscoveredKey]:
69
+ """Get the operator key if present."""
70
+ for key in self.keys:
71
+ if key.role in ("operator", "owner"):
72
+ return key
73
+ return None
74
+
75
+
76
+ @dataclass
77
+ class ImportResult:
78
+ """Result of an import operation."""
79
+
80
+ success: bool
81
+ message: str
82
+ imported_keys: List[str] = field(default_factory=list)
83
+ imported_safes: List[str] = field(default_factory=list)
84
+ imported_services: List[str] = field(default_factory=list)
85
+ skipped: List[str] = field(default_factory=list)
86
+ errors: List[str] = field(default_factory=list)
87
+
88
+
89
+ class OlasServiceImporter:
90
+ """Discover and import Olas services from external directories."""
91
+
92
+ def __init__(self, key_storage: Optional[KeyStorage] = None):
93
+ """Initialize the importer.
94
+
95
+ Args:
96
+ key_storage: KeyStorage instance. If None, will create one.
97
+
98
+ """
99
+ self.key_storage = key_storage or KeyStorage()
100
+ self.config = Config()
101
+
102
+ def scan_directory(self, path: Path) -> List[DiscoveredService]:
103
+ """Recursively scan a directory for Olas services.
104
+
105
+ Args:
106
+ path: Directory to scan.
107
+
108
+ Returns:
109
+ List of discovered services.
110
+
111
+ """
112
+ path = Path(path)
113
+ if not path.exists():
114
+ logger.error(f"Path does not exist: {path}")
115
+ return []
116
+
117
+ discovered = []
118
+
119
+ # Look for .trader_runner folders
120
+ for trader_runner in path.rglob(".trader_runner"):
121
+ if trader_runner.is_dir():
122
+ service = self._parse_trader_runner_format(trader_runner)
123
+ if service:
124
+ discovered.append(service)
125
+
126
+ # Look for .operate folders
127
+ for operate in path.rglob(".operate"):
128
+ if operate.is_dir():
129
+ services = self._parse_operate_format(operate)
130
+ discovered.extend(services)
131
+
132
+ logger.info(f"Discovered {len(discovered)} Olas service(s)")
133
+ return discovered
134
+
135
+ def _parse_trader_runner_format(self, folder: Path) -> Optional[DiscoveredService]:
136
+ """Parse a .trader_runner folder.
137
+
138
+ Expected files:
139
+ - agent_pkey.txt: Encrypted keystore (JSON in .txt)
140
+ - operator_pkey.txt: Encrypted keystore (JSON in .txt)
141
+ - service_id.txt: Service ID
142
+ - service_safe_address.txt: Safe address
143
+ """
144
+ logger.debug(f"Parsing trader_runner format: {folder}")
145
+
146
+ service = DiscoveredService(
147
+ source_folder=folder,
148
+ format="trader_runner",
149
+ service_name=folder.parent.name,
150
+ )
151
+
152
+ service.service_id = self._extract_service_id(folder)
153
+ service.safe_address = self._extract_safe_address(folder)
154
+ service.keys = self._extract_trader_keys(folder)
155
+
156
+ if not service.keys and not service.service_id:
157
+ logger.debug(f"No valid data found in {folder}")
158
+ return None
159
+
160
+ return service
161
+
162
+ def _extract_service_id(self, folder: Path) -> Optional[int]:
163
+ """Extract service ID from file."""
164
+ service_id_file = folder / "service_id.txt"
165
+ if service_id_file.exists():
166
+ try:
167
+ return int(service_id_file.read_text().strip())
168
+ except ValueError:
169
+ logger.warning(f"Invalid service_id in {service_id_file}")
170
+ return None
171
+
172
+ def _extract_safe_address(self, folder: Path) -> Optional[str]:
173
+ """Extract Safe address from file."""
174
+ safe_file = folder / "service_safe_address.txt"
175
+ if safe_file.exists():
176
+ return safe_file.read_text().strip()
177
+ return None
178
+
179
+ def _extract_trader_keys(self, folder: Path) -> List[DiscoveredKey]:
180
+ """Extract all keys from trader runner folder."""
181
+ keys = []
182
+
183
+ # Parse agent_pkey.txt (encrypted keystore in .txt)
184
+ agent_file = folder / "agent_pkey.txt"
185
+ if agent_file.exists():
186
+ key = self._parse_keystore_file(agent_file, role="agent")
187
+ if key:
188
+ keys.append(key)
189
+
190
+ # Parse operator_pkey.txt
191
+ operator_file = folder / "operator_pkey.txt"
192
+ if operator_file.exists():
193
+ key = self._parse_keystore_file(operator_file, role="operator")
194
+ if key:
195
+ keys.append(key)
196
+
197
+ # Also check keys.json (array of keystores)
198
+ keys_file = folder / "keys.json"
199
+ if keys_file.exists():
200
+ additional_keys = self._parse_keys_json(keys_file)
201
+ # Avoid duplicates by address
202
+ existing_addrs = {k.address.lower() for k in keys}
203
+ for key in additional_keys:
204
+ if key.address.lower() not in existing_addrs:
205
+ keys.append(key)
206
+ return keys
207
+
208
+ def _parse_operate_format(self, folder: Path) -> List[DiscoveredService]:
209
+ """Parse a .operate folder.
210
+
211
+ Expected structure:
212
+ - wallets/ethereum.json: Wallet metadata
213
+ - wallets/ethereum.txt: Owner key (plaintext JSON)
214
+ - keys/0x...: Encrypted keystores
215
+ - services/<uuid>/config.json: Service config with keys and service ID
216
+ """
217
+ logger.debug(f"Parsing operate format: {folder}")
218
+
219
+ discovered = []
220
+
221
+ # 1. Try to find services
222
+ services = self._discover_operate_services(folder)
223
+ discovered.extend(services)
224
+
225
+ # 2. If no services, try standalone wallet
226
+ if not discovered:
227
+ wallet_service = self._discover_standalone_wallet(folder)
228
+ if wallet_service:
229
+ discovered.append(wallet_service)
230
+
231
+ return discovered
232
+
233
+ def _discover_operate_services(self, folder: Path) -> List[DiscoveredService]:
234
+ """Discover services within .operate/services folder."""
235
+ services = []
236
+ services_folder = folder / "services"
237
+ if services_folder.exists():
238
+ for service_folder in services_folder.iterdir():
239
+ if service_folder.is_dir():
240
+ config_file = service_folder / "config.json"
241
+ if config_file.exists():
242
+ service = self._parse_operate_service_config(config_file)
243
+ if service:
244
+ services.append(service)
245
+ return services
246
+
247
+ def _discover_standalone_wallet(self, folder: Path) -> Optional[DiscoveredService]:
248
+ """Discover standalone wallet keys in .operate/wallets."""
249
+ wallets_folder = folder / "wallets"
250
+ if not wallets_folder.exists():
251
+ return None
252
+
253
+ # Create a placeholder service for standalone keys
254
+ service = DiscoveredService(
255
+ source_folder=folder,
256
+ format="operate",
257
+ service_name=folder.parent.name,
258
+ )
259
+
260
+ # Parse ethereum.txt (plaintext key)
261
+ eth_txt = wallets_folder / "ethereum.txt"
262
+ if eth_txt.exists():
263
+ key = self._parse_plaintext_key_file(eth_txt, role="owner")
264
+ if key:
265
+ service.keys.append(key)
266
+
267
+ # Parse ethereum.json for Safe info
268
+ eth_json = wallets_folder / "ethereum.json"
269
+ if eth_json.exists():
270
+ try:
271
+ data = json.loads(eth_json.read_text())
272
+ if "safes" in data and "gnosis" in data["safes"]:
273
+ service.safe_address = data["safes"]["gnosis"]
274
+ except (json.JSONDecodeError, KeyError):
275
+ pass
276
+
277
+ if service.keys:
278
+ return service
279
+ return None
280
+
281
+ def _parse_operate_service_config(self, config_file: Path) -> Optional[DiscoveredService]:
282
+ """Parse an operate service config.json file."""
283
+ try:
284
+ data = json.loads(config_file.read_text())
285
+ except json.JSONDecodeError:
286
+ logger.warning(f"Invalid JSON in {config_file}")
287
+ return None
288
+
289
+ # Use the folder name containing .operate (e.g., "trader_xi")
290
+ operate_folder = config_file.parent.parent.parent # services/<uuid> -> .operate
291
+ parent_folder = operate_folder.parent # .operate -> trader_xi
292
+
293
+ service = DiscoveredService(
294
+ source_folder=config_file.parent,
295
+ format="operate",
296
+ service_name=parent_folder.name,
297
+ )
298
+
299
+ # 1. Extract keys from config
300
+ config_keys = self._extract_keys_from_operate_config(data, config_file)
301
+ service.keys.extend(config_keys)
302
+
303
+ # 2. Extract chain info
304
+ self._enrich_service_with_chain_info(service, data)
305
+
306
+ # 3. Check for wallet/owner keys in parent .operate folder
307
+ parent_keys = self._extract_parent_wallet_keys(operate_folder)
308
+ self._merge_unique_keys(service, parent_keys)
309
+
310
+ # 4. Check for encrypted keys in keys folder
311
+ external_keys = self._extract_external_keys_folder(operate_folder)
312
+ self._merge_unique_keys(service, external_keys)
313
+
314
+ return service
315
+
316
+ def _extract_keys_from_operate_config(
317
+ self, data: dict, config_file: Path
318
+ ) -> List[DiscoveredKey]:
319
+ """Extract keys defined inside config.json."""
320
+ keys = []
321
+ if "keys" in data:
322
+ for key_data in data["keys"]:
323
+ if "private_key" in key_data and "address" in key_data:
324
+ private_key = key_data["private_key"]
325
+ # Remove 0x prefix if present
326
+ if private_key.startswith("0x"):
327
+ private_key = private_key[2:]
328
+ keys.append(
329
+ DiscoveredKey(
330
+ address=key_data["address"],
331
+ private_key=private_key,
332
+ role="agent",
333
+ source_file=config_file,
334
+ is_encrypted=False,
335
+ )
336
+ )
337
+ return keys
338
+
339
+ def _enrich_service_with_chain_info(self, service: DiscoveredService, data: dict) -> None:
340
+ """Extract service ID and Safe address from chain configs."""
341
+ chain_configs = data.get("chain_configs", {})
342
+ for chain_name, chain_config in chain_configs.items():
343
+ chain_data = chain_config.get("chain_data", {})
344
+
345
+ # "token" is actually the service_id in operate format
346
+ if "token" in chain_data and isinstance(chain_data["token"], int):
347
+ service.service_id = chain_data["token"]
348
+ service.chain_name = chain_name
349
+
350
+ if "multisig" in chain_data:
351
+ service.safe_address = chain_data["multisig"]
352
+
353
+ def _extract_parent_wallet_keys(self, operate_folder: Path) -> List[DiscoveredKey]:
354
+ """Extract owner keys from parent wallets folder."""
355
+ keys = []
356
+ wallets_folder = operate_folder / "wallets"
357
+ if wallets_folder.exists():
358
+ eth_txt = wallets_folder / "ethereum.txt"
359
+ if eth_txt.exists():
360
+ key = self._parse_plaintext_key_file(eth_txt, role="owner")
361
+ if key:
362
+ keys.append(key)
363
+ return keys
364
+
365
+ def _extract_external_keys_folder(self, operate_folder: Path) -> List[DiscoveredKey]:
366
+ """Extract encrypted keys from the external keys folder."""
367
+ keys = []
368
+ keys_folder = operate_folder / "keys"
369
+ if keys_folder.exists():
370
+ for key_file in keys_folder.iterdir():
371
+ if key_file.is_file() and key_file.name.startswith("0x"):
372
+ key = self._parse_keystore_file(key_file, role="agent")
373
+ if key:
374
+ keys.append(key)
375
+ return keys
376
+
377
+ def _merge_unique_keys(self, service: DiscoveredService, new_keys: List[DiscoveredKey]):
378
+ """Merge new keys into service avoiding duplicates by address."""
379
+ existing_addrs = {k.address.lower() for k in service.keys}
380
+ for key in new_keys:
381
+ if key.address.lower() not in existing_addrs:
382
+ service.keys.append(key)
383
+ existing_addrs.add(key.address.lower())
384
+
385
+ def _parse_keystore_file(
386
+ self, file_path: Path, role: str = "unknown"
387
+ ) -> Optional[DiscoveredKey]:
388
+ """Parse a web3 v3 keystore file."""
389
+ try:
390
+ content = file_path.read_text().strip()
391
+ keystore = json.loads(content)
392
+
393
+ # Validate it's a keystore
394
+ if "crypto" not in keystore or "address" not in keystore:
395
+ return None
396
+
397
+ address = keystore.get("address", "")
398
+ if not address.startswith("0x"):
399
+ address = "0x" + address
400
+
401
+ return DiscoveredKey(
402
+ address=address,
403
+ encrypted_keystore=keystore,
404
+ role=role,
405
+ source_file=file_path,
406
+ is_encrypted=True,
407
+ )
408
+ except (json.JSONDecodeError, IOError) as e:
409
+ logger.warning(f"Failed to parse keystore {file_path}: {e}")
410
+ return None
411
+
412
+ def _parse_keys_json(self, file_path: Path) -> List[DiscoveredKey]:
413
+ """Parse a keys.json file (array of keystores)."""
414
+ try:
415
+ content = json.loads(file_path.read_text())
416
+ if not isinstance(content, list):
417
+ return []
418
+
419
+ keys = []
420
+ for keystore in content:
421
+ if "crypto" in keystore and "address" in keystore:
422
+ address = keystore.get("address", "")
423
+ if not address.startswith("0x"):
424
+ address = "0x" + address
425
+ keys.append(
426
+ DiscoveredKey(
427
+ address=address,
428
+ encrypted_keystore=keystore,
429
+ role="agent",
430
+ source_file=file_path,
431
+ is_encrypted=True,
432
+ )
433
+ )
434
+ return keys
435
+ except (json.JSONDecodeError, IOError):
436
+ return []
437
+
438
+ def _parse_plaintext_key_file(
439
+ self, file_path: Path, role: str = "unknown"
440
+ ) -> Optional[DiscoveredKey]:
441
+ """Parse a file containing a plaintext private key."""
442
+ try:
443
+ content = file_path.read_text().strip()
444
+
445
+ # Try JSON format first ({"ledger": "ethereum", "address": "...", "private_key": "..."})
446
+ try:
447
+ data = json.loads(content)
448
+ if isinstance(data, dict) and "private_key" in data and "address" in data:
449
+ return DiscoveredKey(
450
+ address=data["address"],
451
+ private_key=data["private_key"],
452
+ role=role,
453
+ source_file=file_path,
454
+ is_encrypted=False,
455
+ )
456
+ except json.JSONDecodeError:
457
+ pass
458
+
459
+ # Try raw hex format
460
+ if len(content) == 64 or (len(content) == 66 and content.startswith("0x")):
461
+ private_key = content[2:] if content.startswith("0x") else content
462
+ account = Account.from_key(bytes.fromhex(private_key))
463
+ return DiscoveredKey(
464
+ address=account.address,
465
+ private_key=private_key,
466
+ role=role,
467
+ source_file=file_path,
468
+ is_encrypted=False,
469
+ )
470
+
471
+ return None
472
+ except Exception as e:
473
+ logger.warning(f"Failed to parse plaintext key {file_path}: {e}")
474
+ return None
475
+
476
+ def decrypt_key(self, key: DiscoveredKey, password: str) -> bool:
477
+ """Decrypt an encrypted key.
478
+
479
+ Args:
480
+ key: The key to decrypt (modifies in place).
481
+ password: Password for decryption.
482
+
483
+ Returns:
484
+ True if decryption succeeded.
485
+
486
+ """
487
+ if key.is_decrypted:
488
+ return True
489
+
490
+ if not key.encrypted_keystore:
491
+ logger.error(f"No encrypted keystore for {key.address}")
492
+ return False
493
+
494
+ try:
495
+ private_key = Account.decrypt(key.encrypted_keystore, password)
496
+ key.private_key = private_key.hex()
497
+ key.is_encrypted = False
498
+ return True
499
+ except ValueError as e:
500
+ logger.warning(f"Failed to decrypt {key.address}: {e}")
501
+ return False
502
+
503
+ def import_service(
504
+ self,
505
+ service: DiscoveredService,
506
+ password: Optional[str] = None,
507
+ ) -> ImportResult:
508
+ """Import a discovered service into the wallet.
509
+
510
+ Args:
511
+ service: The service to import.
512
+ password: Password for encrypted keys (if any).
513
+
514
+ Returns:
515
+ ImportResult with details of what was imported.
516
+
517
+ """
518
+ result = ImportResult(success=True, message="")
519
+
520
+ self._import_discovered_keys(service, password, result)
521
+ self._import_discovered_safes(service, result)
522
+ self._import_discovered_service_config(service, result)
523
+ self._build_import_summary(result)
524
+
525
+ return result
526
+
527
+ def _import_discovered_keys(
528
+ self, service: DiscoveredService, password: Optional[str], result: ImportResult
529
+ ) -> None:
530
+ """Import keys from the service."""
531
+ for key in service.keys:
532
+ key_result = self._import_key(key, service.service_name, password)
533
+ if key_result[0]:
534
+ result.imported_keys.append(key.address)
535
+ elif key_result[1] == "duplicate":
536
+ result.skipped.append(f"Key {key.address} (already exists)")
537
+ else:
538
+ result.errors.append(f"Key {key.address}: {key_result[1]}")
539
+ result.success = False
540
+
541
+ def _import_discovered_safes(self, service: DiscoveredService, result: ImportResult) -> None:
542
+ """Import Safe from the service if present."""
543
+ if service.safe_address:
544
+ safe_result = self._import_safe(service)
545
+ if safe_result[0]:
546
+ result.imported_safes.append(service.safe_address)
547
+ elif safe_result[1] == "duplicate":
548
+ result.skipped.append(f"Safe {service.safe_address} (already exists)")
549
+ else:
550
+ result.errors.append(f"Safe {service.safe_address}: {safe_result[1]}")
551
+
552
+ def _import_discovered_service_config(
553
+ self, service: DiscoveredService, result: ImportResult
554
+ ) -> None:
555
+ """Import service config to OlasConfig."""
556
+ if service.service_id:
557
+ svc_result = self._import_service_config(service)
558
+ if svc_result[0]:
559
+ result.imported_services.append(f"{service.chain_name}:{service.service_id}")
560
+ elif svc_result[1] == "duplicate":
561
+ result.skipped.append(
562
+ f"Service {service.chain_name}:{service.service_id} (already exists)"
563
+ )
564
+ else:
565
+ result.errors.append(
566
+ f"Service {service.chain_name}:{service.service_id}: {svc_result[1]}"
567
+ )
568
+
569
+ def _build_import_summary(self, result: ImportResult) -> None:
570
+ """Build the summary message for the import result."""
571
+ parts = []
572
+ if result.imported_keys:
573
+ parts.append(f"{len(result.imported_keys)} key(s)")
574
+ if result.imported_safes:
575
+ parts.append(f"{len(result.imported_safes)} safe(s)")
576
+ if result.imported_services:
577
+ parts.append(f"{len(result.imported_services)} service(s)")
578
+
579
+ if parts:
580
+ result.message = f"Imported {', '.join(parts)}"
581
+ else:
582
+ result.message = "Nothing imported"
583
+
584
+ def _import_key(
585
+ self, key: DiscoveredKey, service_name: Optional[str], password: Optional[str] = None
586
+ ) -> Tuple[bool, str]:
587
+ """Import a single key.
588
+
589
+ Returns:
590
+ Tuple of (success, error_message_or_status)
591
+
592
+ """
593
+ # Check for duplicate
594
+ existing = self.key_storage.find_stored_account(key.address)
595
+ if existing:
596
+ return False, "duplicate"
597
+
598
+ # Decrypt if needed
599
+ if not key.is_decrypted:
600
+ if not password:
601
+ return False, "encrypted key requires password"
602
+ if not self.decrypt_key(key, password):
603
+ return False, "decryption failed"
604
+
605
+ # Generate tag
606
+ tag = self._generate_tag(key, service_name)
607
+
608
+ # Re-encrypt with our password and save
609
+ try:
610
+ encrypted = EncryptedAccount.encrypt_private_key(
611
+ key.private_key,
612
+ self.key_storage._password,
613
+ tag,
614
+ )
615
+ self.key_storage.accounts[encrypted.address] = encrypted
616
+ self.key_storage.save()
617
+ logger.info(f"Imported key {key.address} as '{tag}'")
618
+ return True, "ok"
619
+ except Exception as e:
620
+ return False, str(e)
621
+
622
+ def _generate_tag(self, key: DiscoveredKey, service_name: Optional[str]) -> str:
623
+ """Generate a unique tag for an imported key.
624
+
625
+ Tags follow the pattern: {service_name}_{role}
626
+ Example: trader_alpha_agent, trader_alpha_operator
627
+ """
628
+ import re
629
+
630
+ # Use service name as prefix, or 'imported' as fallback
631
+ prefix = service_name or "imported"
632
+
633
+ # Normalize: lowercase, replace spaces/special chars with underscores
634
+ prefix = re.sub(r"[^a-z0-9]+", "_", prefix.lower()).strip("_")
635
+ role = re.sub(r"[^a-z0-9]+", "_", key.role.lower()).strip("_")
636
+
637
+ base_tag = f"{prefix}_{role}"
638
+
639
+ # Check if tag already exists
640
+ existing_tags = {
641
+ acc.tag for acc in self.key_storage.accounts.values() if hasattr(acc, "tag")
642
+ }
643
+
644
+ if base_tag not in existing_tags:
645
+ return base_tag
646
+
647
+ # Add numeric suffix if tag already exists
648
+ i = 2
649
+ while f"{base_tag}_{i}" in existing_tags:
650
+ i += 1
651
+ return f"{base_tag}_{i}"
652
+
653
+ def _import_safe(self, service: DiscoveredService) -> Tuple[bool, str]:
654
+ """Import a Safe from a discovered service."""
655
+ if not service.safe_address:
656
+ return False, "no safe address"
657
+
658
+ # Check for duplicate
659
+ existing = self.key_storage.find_stored_account(service.safe_address)
660
+ if existing:
661
+ return False, "duplicate"
662
+
663
+ # Get signers from agent keys
664
+ signers = []
665
+ for key in service.keys:
666
+ if key.role == "agent":
667
+ signers.append(key.address)
668
+
669
+ # Generate tag
670
+ import re
671
+
672
+ prefix = service.service_name or "imported"
673
+ prefix = re.sub(r"[^a-z0-9]+", "_", prefix.lower()).strip("_")
674
+ base_tag = f"{prefix}_safe"
675
+
676
+ existing_tags = {
677
+ acc.tag for acc in self.key_storage.accounts.values() if hasattr(acc, "tag")
678
+ }
679
+ tag = base_tag
680
+ i = 2
681
+ while tag in existing_tags:
682
+ tag = f"{base_tag}_{i}"
683
+ i += 1
684
+
685
+ safe_account = StoredSafeAccount(
686
+ tag=tag,
687
+ address=service.safe_address,
688
+ chains=[service.chain_name],
689
+ threshold=1, # Default, accurate value requires on-chain query
690
+ signers=signers,
691
+ )
692
+
693
+ self.key_storage.accounts[service.safe_address] = safe_account
694
+ self.key_storage.save()
695
+ logger.info(f"Imported Safe {service.safe_address} as '{tag}'")
696
+ return True, "ok"
697
+
698
+ def _import_service_config(self, service: DiscoveredService) -> Tuple[bool, str]:
699
+ """Import service config to OlasConfig."""
700
+ try:
701
+ from iwa.plugins.olas.models import OlasConfig, Service
702
+
703
+ # Get or create OlasConfig
704
+ if "olas" not in self.config.plugins:
705
+ self.config.plugins["olas"] = OlasConfig()
706
+
707
+ olas_config: OlasConfig = self.config.plugins["olas"]
708
+
709
+ # Check for duplicate
710
+ key = f"{service.chain_name}:{service.service_id}"
711
+ if key in olas_config.services:
712
+ return False, "duplicate"
713
+
714
+ # Create service model
715
+ olas_service = Service(
716
+ service_name=service.service_name or f"service_{service.service_id}",
717
+ chain_name=service.chain_name,
718
+ service_id=service.service_id,
719
+ agent_ids=[], # Would need on-chain query
720
+ multisig_address=service.safe_address,
721
+ )
722
+
723
+ # Set agent address if we have one
724
+ agent_key = service.agent_key
725
+ if agent_key:
726
+ olas_service.agent_address = agent_key.address
727
+
728
+ olas_config.add_service(olas_service)
729
+ self.config.save()
730
+ logger.info(f"Imported service {key}")
731
+ return True, "ok"
732
+
733
+ except ImportError:
734
+ return False, "Olas plugin not available"
735
+ except Exception as e:
736
+ return False, str(e)