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.
- conftest.py +22 -0
- iwa/__init__.py +1 -0
- iwa/__main__.py +6 -0
- iwa/core/__init__.py +1 -0
- iwa/core/chain/__init__.py +68 -0
- iwa/core/chain/errors.py +47 -0
- iwa/core/chain/interface.py +514 -0
- iwa/core/chain/manager.py +38 -0
- iwa/core/chain/models.py +128 -0
- iwa/core/chain/rate_limiter.py +193 -0
- iwa/core/cli.py +210 -0
- iwa/core/constants.py +28 -0
- iwa/core/contracts/__init__.py +1 -0
- iwa/core/contracts/contract.py +297 -0
- iwa/core/contracts/erc20.py +79 -0
- iwa/core/contracts/multisend.py +71 -0
- iwa/core/db.py +317 -0
- iwa/core/keys.py +361 -0
- iwa/core/mnemonic.py +385 -0
- iwa/core/models.py +344 -0
- iwa/core/monitor.py +209 -0
- iwa/core/plugins.py +45 -0
- iwa/core/pricing.py +91 -0
- iwa/core/services/__init__.py +17 -0
- iwa/core/services/account.py +57 -0
- iwa/core/services/balance.py +113 -0
- iwa/core/services/plugin.py +88 -0
- iwa/core/services/safe.py +392 -0
- iwa/core/services/transaction.py +172 -0
- iwa/core/services/transfer/__init__.py +166 -0
- iwa/core/services/transfer/base.py +260 -0
- iwa/core/services/transfer/erc20.py +247 -0
- iwa/core/services/transfer/multisend.py +386 -0
- iwa/core/services/transfer/native.py +262 -0
- iwa/core/services/transfer/swap.py +326 -0
- iwa/core/settings.py +95 -0
- iwa/core/tables.py +60 -0
- iwa/core/test.py +27 -0
- iwa/core/tests/test_wallet.py +255 -0
- iwa/core/types.py +59 -0
- iwa/core/ui.py +99 -0
- iwa/core/utils.py +59 -0
- iwa/core/wallet.py +380 -0
- iwa/plugins/__init__.py +1 -0
- iwa/plugins/gnosis/__init__.py +5 -0
- iwa/plugins/gnosis/cow/__init__.py +6 -0
- iwa/plugins/gnosis/cow/quotes.py +148 -0
- iwa/plugins/gnosis/cow/swap.py +403 -0
- iwa/plugins/gnosis/cow/types.py +20 -0
- iwa/plugins/gnosis/cow_utils.py +44 -0
- iwa/plugins/gnosis/plugin.py +68 -0
- iwa/plugins/gnosis/safe.py +157 -0
- iwa/plugins/gnosis/tests/test_cow.py +227 -0
- iwa/plugins/gnosis/tests/test_safe.py +100 -0
- iwa/plugins/olas/__init__.py +5 -0
- iwa/plugins/olas/constants.py +106 -0
- iwa/plugins/olas/contracts/activity_checker.py +93 -0
- iwa/plugins/olas/contracts/base.py +10 -0
- iwa/plugins/olas/contracts/mech.py +49 -0
- iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
- iwa/plugins/olas/contracts/service.py +215 -0
- iwa/plugins/olas/contracts/staking.py +403 -0
- iwa/plugins/olas/importer.py +736 -0
- iwa/plugins/olas/mech_reference.py +135 -0
- iwa/plugins/olas/models.py +110 -0
- iwa/plugins/olas/plugin.py +243 -0
- iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
- iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
- iwa/plugins/olas/service_manager/__init__.py +60 -0
- iwa/plugins/olas/service_manager/base.py +113 -0
- iwa/plugins/olas/service_manager/drain.py +336 -0
- iwa/plugins/olas/service_manager/lifecycle.py +839 -0
- iwa/plugins/olas/service_manager/mech.py +322 -0
- iwa/plugins/olas/service_manager/staking.py +530 -0
- iwa/plugins/olas/tests/conftest.py +30 -0
- iwa/plugins/olas/tests/test_importer.py +128 -0
- iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
- iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
- iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
- iwa/plugins/olas/tests/test_olas_integration.py +561 -0
- iwa/plugins/olas/tests/test_olas_models.py +144 -0
- iwa/plugins/olas/tests/test_olas_view.py +258 -0
- iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
- iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
- iwa/plugins/olas/tests/test_plugin.py +70 -0
- iwa/plugins/olas/tests/test_plugin_full.py +212 -0
- iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
- iwa/plugins/olas/tests/test_service_manager.py +1065 -0
- iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
- iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
- iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
- iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
- iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
- iwa/plugins/olas/tests/test_service_staking.py +342 -0
- iwa/plugins/olas/tests/test_staking_integration.py +269 -0
- iwa/plugins/olas/tests/test_staking_validation.py +109 -0
- iwa/plugins/olas/tui/__init__.py +1 -0
- iwa/plugins/olas/tui/olas_view.py +952 -0
- iwa/tools/check_profile.py +67 -0
- iwa/tools/release.py +111 -0
- iwa/tools/reset_env.py +111 -0
- iwa/tools/reset_tenderly.py +362 -0
- iwa/tools/restore_backup.py +82 -0
- iwa/tui/__init__.py +1 -0
- iwa/tui/app.py +174 -0
- iwa/tui/modals/__init__.py +5 -0
- iwa/tui/modals/base.py +406 -0
- iwa/tui/rpc.py +63 -0
- iwa/tui/screens/__init__.py +1 -0
- iwa/tui/screens/wallets.py +749 -0
- iwa/tui/tests/test_app.py +125 -0
- iwa/tui/tests/test_rpc.py +139 -0
- iwa/tui/tests/test_wallets_refactor.py +30 -0
- iwa/tui/tests/test_widgets.py +123 -0
- iwa/tui/widgets/__init__.py +5 -0
- iwa/tui/widgets/base.py +100 -0
- iwa/tui/workers.py +42 -0
- iwa/web/dependencies.py +76 -0
- iwa/web/models.py +76 -0
- iwa/web/routers/accounts.py +115 -0
- iwa/web/routers/olas/__init__.py +24 -0
- iwa/web/routers/olas/admin.py +169 -0
- iwa/web/routers/olas/funding.py +135 -0
- iwa/web/routers/olas/general.py +29 -0
- iwa/web/routers/olas/services.py +378 -0
- iwa/web/routers/olas/staking.py +341 -0
- iwa/web/routers/state.py +65 -0
- iwa/web/routers/swap.py +617 -0
- iwa/web/routers/transactions.py +153 -0
- iwa/web/server.py +155 -0
- iwa/web/tests/test_web_endpoints.py +713 -0
- iwa/web/tests/test_web_olas.py +430 -0
- iwa/web/tests/test_web_swap.py +103 -0
- iwa-0.0.1a2.dist-info/METADATA +234 -0
- iwa-0.0.1a2.dist-info/RECORD +186 -0
- iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
- iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
- iwa-0.0.1a2.dist-info/top_level.txt +4 -0
- tests/legacy_cow.py +248 -0
- tests/legacy_safe.py +93 -0
- tests/legacy_transaction_retry_logic.py +51 -0
- tests/legacy_tui.py +440 -0
- tests/legacy_wallets_screen.py +554 -0
- tests/legacy_web.py +243 -0
- tests/test_account_service.py +120 -0
- tests/test_balance_service.py +186 -0
- tests/test_chain.py +490 -0
- tests/test_chain_interface.py +210 -0
- tests/test_cli.py +139 -0
- tests/test_contract.py +195 -0
- tests/test_db.py +180 -0
- tests/test_drain_coverage.py +174 -0
- tests/test_erc20.py +95 -0
- tests/test_gnosis_plugin.py +111 -0
- tests/test_keys.py +449 -0
- tests/test_legacy_wallet.py +1285 -0
- tests/test_main.py +13 -0
- tests/test_mnemonic.py +217 -0
- tests/test_modals.py +109 -0
- tests/test_models.py +213 -0
- tests/test_monitor.py +202 -0
- tests/test_multisend.py +84 -0
- tests/test_plugin_service.py +119 -0
- tests/test_pricing.py +143 -0
- tests/test_rate_limiter.py +199 -0
- tests/test_reset_tenderly.py +202 -0
- tests/test_rpc_view.py +73 -0
- tests/test_safe_coverage.py +139 -0
- tests/test_safe_service.py +168 -0
- tests/test_service_manager_integration.py +61 -0
- tests/test_service_manager_structure.py +31 -0
- tests/test_service_transaction.py +176 -0
- tests/test_staking_router.py +71 -0
- tests/test_staking_simple.py +31 -0
- tests/test_tables.py +76 -0
- tests/test_transaction_service.py +161 -0
- tests/test_transfer_multisend.py +179 -0
- tests/test_transfer_native.py +220 -0
- tests/test_transfer_security.py +93 -0
- tests/test_transfer_structure.py +37 -0
- tests/test_transfer_swap_unit.py +155 -0
- tests/test_ui_coverage.py +66 -0
- tests/test_utils.py +53 -0
- tests/test_workers.py +91 -0
- tools/verify_drain.py +183 -0
- __init__.py +0 -2
- hello.py +0 -6
- iwa-0.0.0.dist-info/METADATA +0 -10
- iwa-0.0.0.dist-info/RECORD +0 -6
- iwa-0.0.0.dist-info/top_level.txt +0 -2
- {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)
|