iwa 0.0.21__py3-none-any.whl → 0.0.23__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.
@@ -17,6 +17,28 @@ from loguru import logger
17
17
  from iwa.core.keys import EncryptedAccount, KeyStorage
18
18
  from iwa.core.models import Config, StoredSafeAccount
19
19
 
20
+ # Known mappings from olas-operate-middleware staking programs
21
+ # See: https://github.com/valory-xyz/olas-operate-middleware/blob/main/operate/ledger/profiles.py
22
+ STAKING_PROGRAM_MAP = {
23
+ # Pearl staking programs (gnosis) - operate format
24
+ "pearl_alpha": "0x5344B7DD311e5d3DdDd46A4f71481Bd7b05AAA3e", # Expert Legacy
25
+ "pearl_beta": "0x389B46C259631Acd6a69Bde8B6cEe218230bAE8C", # Hobbyist 1 Legacy
26
+ "pearl_beta_2": "0xE56dF1E563De1B10715cB313D514af350D207212", # Expert 5 Legacy
27
+ "pearl_beta_3": "0xD7A3C8b975f71030135f1a66E9e23164d54fF455", # Expert 7 Legacy
28
+ "pearl_beta_4": "0x17dBAe44BC5618Cc254055B386A29576b4F87015", # Expert 9 Legacy
29
+ "pearl_beta_5": "0xB0ef657b8302bd2c74B6E6D9B2b4b39145b19c6f", # Expert 10 Legacy
30
+ "pearl_beta_mm_v2_1": "0x75eeca6207be98cac3fde8a20ecd7b01e50b3472", # Expert 3 MM v2
31
+ "pearl_beta_mm_v2_2": "0x9c7f6103e3a72e4d1805b9c683ea5b370ec1a99f", # Expert 4 MM v2
32
+ "pearl_beta_mm_v2_3": "0xcdC603e0Ee55Aae92519f9770f214b2Be4967f7d", # Expert 5 MM v2
33
+ # Quickstart staking programs (gnosis) - quickstart format
34
+ "quickstart_beta_expert_4": "0xaD9d891134443B443D7F30013c7e14Fe27F2E029", # Expert 4 Legacy
35
+ "quickstart_beta_expert_7": "0xD7A3C8b975f71030135f1a66E9e23164d54fF455", # Expert 7 Legacy
36
+ "quickstart_beta_expert_9": "0x17dBAe44BC5618Cc254055B386A29576b4F87015", # Expert 9 Legacy
37
+ "quickstart_beta_expert_11": "0x3112c1613eAC3dBAE3D4E38CeF023eb9E2C91CF7", # Expert 11 Legacy
38
+ "quickstart_beta_expert_16_mech_marketplace": "0x6c65430515c70a3f5E62107CC301685B7D46f991", # Expert 16 MM v1
39
+ "quickstart_beta_expert_18_mech_marketplace": "0x041e679d04Fc0D4f75Eb937Dea729Df09a58e454", # Expert 18 MM v1
40
+ }
41
+
20
42
 
21
43
  @dataclass
22
44
  class DiscoveredKey:
@@ -26,8 +48,10 @@ class DiscoveredKey:
26
48
  private_key: Optional[str] = None # Plaintext hex (None if still encrypted)
27
49
  encrypted_keystore: Optional[dict] = None # Web3 v3 keystore format
28
50
  source_file: Path = field(default_factory=Path)
29
- role: str = "unknown" # "agent", "operator", "owner"
51
+ role: str = "unknown" # "agent", "owner"
30
52
  is_encrypted: bool = False
53
+ signature_verified: bool = False
54
+ signature_failed: bool = False
31
55
 
32
56
  @property
33
57
  def is_decrypted(self) -> bool:
@@ -55,6 +79,9 @@ class DiscoveredService:
55
79
  source_folder: Path = field(default_factory=Path)
56
80
  format: str = "unknown" # "trader_runner" or "operate"
57
81
  service_name: Optional[str] = None
82
+ # New fields for full service import
83
+ staking_contract_address: Optional[str] = None
84
+ service_owner_address: Optional[str] = None
58
85
 
59
86
  @property
60
87
  def agent_key(self) -> Optional[DiscoveredKey]:
@@ -66,9 +93,14 @@ class DiscoveredService:
66
93
 
67
94
  @property
68
95
  def operator_key(self) -> Optional[DiscoveredKey]:
69
- """Get the operator key if present."""
96
+ """Get the operator (owner) key. Alias for compatibility."""
97
+ return self.owner_key
98
+
99
+ @property
100
+ def owner_key(self) -> Optional[DiscoveredKey]:
101
+ """Get the owner key if present (matches 'owner' or 'operator' roles)."""
70
102
  for key in self.keys:
71
- if key.role in ("operator", "owner"):
103
+ if key.role in ["owner", "operator"]:
72
104
  return key
73
105
  return None
74
106
 
@@ -89,15 +121,17 @@ class ImportResult:
89
121
  class OlasServiceImporter:
90
122
  """Discover and import Olas services from external directories."""
91
123
 
92
- def __init__(self, key_storage: Optional[KeyStorage] = None):
124
+ def __init__(self, key_storage: Optional[KeyStorage] = None, password: Optional[str] = None):
93
125
  """Initialize the importer.
94
126
 
95
127
  Args:
96
128
  key_storage: KeyStorage instance. If None, will create one.
129
+ password: Optional password to decrypt discovered keystores.
97
130
 
98
131
  """
99
132
  self.key_storage = key_storage or KeyStorage()
100
133
  self.config = Config()
134
+ self.password = password
101
135
 
102
136
  def scan_directory(self, path: Path) -> List[DiscoveredService]:
103
137
  """Recursively scan a directory for Olas services.
@@ -106,7 +140,7 @@ class OlasServiceImporter:
106
140
  path: Directory to scan.
107
141
 
108
142
  Returns:
109
- List of discovered services.
143
+ List of discovered services (deduplicated by chain:service_id).
110
144
 
111
145
  """
112
146
  path = Path(path)
@@ -129,8 +163,51 @@ class OlasServiceImporter:
129
163
  services = self._parse_operate_format(operate)
130
164
  discovered.extend(services)
131
165
 
132
- logger.info(f"Discovered {len(discovered)} Olas service(s)")
133
- return discovered
166
+ return self._deduplicate_services(discovered)
167
+
168
+ def _deduplicate_services(self, services: List[DiscoveredService]) -> List[DiscoveredService]:
169
+ """Deduplicate discovered services by chain:service_id."""
170
+ seen_keys: set = set()
171
+ unique_services = []
172
+ duplicates = 0
173
+ for service in services:
174
+ if service.service_id:
175
+ key = f"{service.chain_name}:{service.service_id}"
176
+ if key in seen_keys:
177
+ logger.debug(
178
+ f"Skipping duplicate service {key} from {service.source_folder}"
179
+ )
180
+ duplicates += 1
181
+ continue
182
+ seen_keys.add(key)
183
+ unique_services.append(service)
184
+
185
+ if duplicates:
186
+ logger.info(f"Skipped {duplicates} duplicate service(s)")
187
+ logger.info(f"Discovered {len(unique_services)} unique Olas service(s)")
188
+ return unique_services
189
+
190
+ def _find_trader_name(self, folder: Path) -> str:
191
+ """Find the trader name by traversing up the directory tree.
192
+
193
+ Handles quickstart format where the .operate folder is nested inside
194
+ a quickstart folder, e.g.: trader_altair/quickstart/.operate/
195
+
196
+ Returns the first folder name starting with 'trader_' or the
197
+ immediate folder name if none found.
198
+ """
199
+ current = folder
200
+ fallback = folder.name
201
+
202
+ # Traverse up looking for trader_* folder
203
+ for _ in range(5): # Max 5 levels up
204
+ if current.name.startswith("trader_"):
205
+ return current.name
206
+ current = current.parent
207
+ if current == current.parent: # Reached root
208
+ break
209
+
210
+ return fallback
134
211
 
135
212
  def _parse_trader_runner_format(self, folder: Path) -> Optional[DiscoveredService]:
136
213
  """Parse a .trader_runner folder.
@@ -153,6 +230,9 @@ class OlasServiceImporter:
153
230
  service.safe_address = self._extract_safe_address(folder)
154
231
  service.keys = self._extract_trader_keys(folder)
155
232
 
233
+ # Extract staking program from .env
234
+ self._extract_staking_from_env(service, folder)
235
+
156
236
  if not service.keys and not service.service_id:
157
237
  logger.debug(f"No valid data found in {folder}")
158
238
  return None
@@ -187,12 +267,13 @@ class OlasServiceImporter:
187
267
  if key:
188
268
  keys.append(key)
189
269
 
190
- # Parse operator_pkey.txt
270
+ # Parse operator_pkey.txt (contains owner key)
191
271
  operator_file = folder / "operator_pkey.txt"
192
272
  if operator_file.exists():
193
- key = self._parse_keystore_file(operator_file, role="operator")
273
+ key = self._parse_keystore_file(operator_file, role="owner")
194
274
  if key:
195
275
  keys.append(key)
276
+ self._verify_key_signature(key)
196
277
 
197
278
  # Also check keys.json (array of keystores)
198
279
  keys_file = folder / "keys.json"
@@ -205,6 +286,31 @@ class OlasServiceImporter:
205
286
  keys.append(key)
206
287
  return keys
207
288
 
289
+ def _extract_staking_from_env(self, service: DiscoveredService, folder: Path) -> None:
290
+ """Extract STAKING_PROGRAM from .env file in trader_runner folder."""
291
+ # Check parent folder for .env (usually alongside .trader_runner)
292
+ env_file = folder.parent / ".env"
293
+ if not env_file.exists():
294
+ # Also check inside the folder itself
295
+ env_file = folder / ".env"
296
+ if not env_file.exists():
297
+ return
298
+
299
+ try:
300
+ content = env_file.read_text()
301
+ for line in content.splitlines():
302
+ line = line.strip()
303
+ if line.startswith("STAKING_PROGRAM="):
304
+ program_id = line.split("=", 1)[1].strip().strip('"').strip("'")
305
+ if program_id:
306
+ service.staking_contract_address = self._resolve_staking_contract(
307
+ program_id, service.chain_name
308
+ )
309
+ logger.debug(f"Found STAKING_PROGRAM={program_id} in {env_file}")
310
+ break
311
+ except IOError as e:
312
+ logger.warning(f"Failed to read {env_file}: {e}")
313
+
208
314
  def _parse_operate_format(self, folder: Path) -> List[DiscoveredService]:
209
315
  """Parse a .operate folder.
210
316
 
@@ -288,12 +394,15 @@ class OlasServiceImporter:
288
394
 
289
395
  # Use the folder name containing .operate (e.g., "trader_xi")
290
396
  operate_folder = config_file.parent.parent.parent # services/<uuid> -> .operate
291
- parent_folder = operate_folder.parent # .operate -> trader_xi
397
+ parent_folder = operate_folder.parent # .operate -> trader_xi or quickstart
398
+
399
+ # Handle quickstart format: traverse up to find trader_* folder
400
+ service_name = self._find_trader_name(parent_folder)
292
401
 
293
402
  service = DiscoveredService(
294
403
  source_folder=config_file.parent,
295
404
  format="operate",
296
- service_name=parent_folder.name,
405
+ service_name=service_name,
297
406
  )
298
407
 
299
408
  # 1. Extract keys from config
@@ -311,6 +420,9 @@ class OlasServiceImporter:
311
420
  external_keys = self._extract_external_keys_folder(operate_folder)
312
421
  self._merge_unique_keys(service, external_keys)
313
422
 
423
+ # 5. Extract owner address from wallets folder
424
+ self._extract_owner_address(service, operate_folder)
425
+
314
426
  return service
315
427
 
316
428
  def _extract_keys_from_operate_config(
@@ -325,19 +437,19 @@ class OlasServiceImporter:
325
437
  # Remove 0x prefix if present
326
438
  if private_key.startswith("0x"):
327
439
  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
- )
440
+ key = DiscoveredKey(
441
+ address=key_data["address"],
442
+ private_key=private_key,
443
+ role="agent",
444
+ source_file=config_file,
445
+ is_encrypted=False,
336
446
  )
447
+ self._verify_key_signature(key)
448
+ keys.append(key)
337
449
  return keys
338
450
 
339
451
  def _enrich_service_with_chain_info(self, service: DiscoveredService, data: dict) -> None:
340
- """Extract service ID and Safe address from chain configs."""
452
+ """Extract service ID, Safe address, and staking contract from chain configs."""
341
453
  chain_configs = data.get("chain_configs", {})
342
454
  for chain_name, chain_config in chain_configs.items():
343
455
  chain_data = chain_config.get("chain_data", {})
@@ -350,6 +462,25 @@ class OlasServiceImporter:
350
462
  if "multisig" in chain_data:
351
463
  service.safe_address = chain_data["multisig"]
352
464
 
465
+ # Extract staking contract from user_params
466
+ user_params = chain_data.get("user_params", {})
467
+ staking_program_id = user_params.get("staking_program_id")
468
+ if staking_program_id:
469
+ service.staking_contract_address = self._resolve_staking_contract(
470
+ staking_program_id, chain_name
471
+ )
472
+
473
+ def _resolve_staking_contract(
474
+ self, staking_program_id: str, chain_name: str
475
+ ) -> Optional[str]:
476
+ """Resolve a staking program ID to a contract address."""
477
+ address = STAKING_PROGRAM_MAP.get(staking_program_id)
478
+ if address:
479
+ logger.debug(f"Resolved staking program '{staking_program_id}' -> {address}")
480
+ else:
481
+ logger.warning(f"Unknown staking program ID: {staking_program_id}")
482
+ return address
483
+
353
484
  def _extract_parent_wallet_keys(self, operate_folder: Path) -> List[DiscoveredKey]:
354
485
  """Extract owner keys from parent wallets folder."""
355
486
  keys = []
@@ -357,7 +488,12 @@ class OlasServiceImporter:
357
488
  if wallets_folder.exists():
358
489
  eth_txt = wallets_folder / "ethereum.txt"
359
490
  if eth_txt.exists():
491
+ # Try plaintext first
360
492
  key = self._parse_plaintext_key_file(eth_txt, role="owner")
493
+ if not key:
494
+ # Fallback to keystore
495
+ key = self._parse_keystore_file(eth_txt, role="owner")
496
+
361
497
  if key:
362
498
  keys.append(key)
363
499
  return keys
@@ -374,6 +510,22 @@ class OlasServiceImporter:
374
510
  keys.append(key)
375
511
  return keys
376
512
 
513
+ def _extract_owner_address(self, service: DiscoveredService, operate_folder: Path) -> None:
514
+ """Extract owner address from wallets/ethereum.json."""
515
+ wallets_folder = operate_folder / "wallets"
516
+ if not wallets_folder.exists():
517
+ return
518
+
519
+ eth_json = wallets_folder / "ethereum.json"
520
+ if eth_json.exists():
521
+ try:
522
+ data = json.loads(eth_json.read_text())
523
+ if "address" in data:
524
+ service.service_owner_address = data["address"]
525
+ logger.debug(f"Extracted owner address: {service.service_owner_address}")
526
+ except (json.JSONDecodeError, IOError) as e:
527
+ logger.warning(f"Failed to parse {eth_json}: {e}")
528
+
377
529
  def _merge_unique_keys(self, service: DiscoveredService, new_keys: List[DiscoveredKey]):
378
530
  """Merge new keys into service avoiding duplicates by address."""
379
531
  existing_addrs = {k.address.lower() for k in service.keys}
@@ -398,13 +550,21 @@ class OlasServiceImporter:
398
550
  if not address.startswith("0x"):
399
551
  address = "0x" + address
400
552
 
401
- return DiscoveredKey(
553
+ key = DiscoveredKey(
402
554
  address=address,
403
555
  encrypted_keystore=keystore,
404
556
  role=role,
405
557
  source_file=file_path,
406
558
  is_encrypted=True,
407
559
  )
560
+
561
+ # Attempt decryption if password provided
562
+ if self.password:
563
+ self._attempt_decryption(key)
564
+ if key.private_key:
565
+ self._verify_key_signature(key)
566
+
567
+ return key
408
568
  except (json.JSONDecodeError, IOError) as e:
409
569
  logger.warning(f"Failed to parse keystore {file_path}: {e}")
410
570
  return None
@@ -422,15 +582,19 @@ class OlasServiceImporter:
422
582
  address = keystore.get("address", "")
423
583
  if not address.startswith("0x"):
424
584
  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
- )
585
+ key = DiscoveredKey(
586
+ address=address,
587
+ encrypted_keystore=keystore,
588
+ role="agent",
589
+ source_file=file_path,
590
+ is_encrypted=True,
433
591
  )
592
+ # Attempt decryption if password provided
593
+ if self.password:
594
+ self._attempt_decryption(key)
595
+ if key.private_key:
596
+ self._verify_key_signature(key)
597
+ keys.append(key)
434
598
  return keys
435
599
  except (json.JSONDecodeError, IOError):
436
600
  return []
@@ -446,13 +610,15 @@ class OlasServiceImporter:
446
610
  try:
447
611
  data = json.loads(content)
448
612
  if isinstance(data, dict) and "private_key" in data and "address" in data:
449
- return DiscoveredKey(
613
+ key = DiscoveredKey(
450
614
  address=data["address"],
451
615
  private_key=data["private_key"],
452
616
  role=role,
453
617
  source_file=file_path,
454
618
  is_encrypted=False,
455
619
  )
620
+ self._verify_key_signature(key)
621
+ return key
456
622
  except json.JSONDecodeError:
457
623
  pass
458
624
 
@@ -460,13 +626,15 @@ class OlasServiceImporter:
460
626
  if len(content) == 64 or (len(content) == 66 and content.startswith("0x")):
461
627
  private_key = content[2:] if content.startswith("0x") else content
462
628
  account = Account.from_key(bytes.fromhex(private_key))
463
- return DiscoveredKey(
629
+ key = DiscoveredKey(
464
630
  address=account.address,
465
631
  private_key=private_key,
466
632
  role=role,
467
633
  source_file=file_path,
468
634
  is_encrypted=False,
469
635
  )
636
+ self._verify_key_signature(key)
637
+ return key
470
638
 
471
639
  return None
472
640
  except Exception as e:
@@ -698,6 +866,7 @@ class OlasServiceImporter:
698
866
  def _import_service_config(self, service: DiscoveredService) -> Tuple[bool, str]:
699
867
  """Import service config to OlasConfig."""
700
868
  try:
869
+ from iwa.plugins.olas.constants import OLAS_TOKEN_ADDRESS_GNOSIS
701
870
  from iwa.plugins.olas.models import OlasConfig, Service
702
871
 
703
872
  # Get or create OlasConfig
@@ -711,13 +880,16 @@ class OlasServiceImporter:
711
880
  if key in olas_config.services:
712
881
  return False, "duplicate"
713
882
 
714
- # Create service model
883
+ # Create service model with all fields
715
884
  olas_service = Service(
716
885
  service_name=service.service_name or f"service_{service.service_id}",
717
886
  chain_name=service.chain_name,
718
887
  service_id=service.service_id,
719
- agent_ids=[], # Would need on-chain query
888
+ agent_ids=[25], # Trader agents always use agent ID 25
720
889
  multisig_address=service.safe_address,
890
+ service_owner_address=service.service_owner_address,
891
+ staking_contract_address=service.staking_contract_address,
892
+ token_address=str(OLAS_TOKEN_ADDRESS_GNOSIS),
721
893
  )
722
894
 
723
895
  # Set agent address if we have one
@@ -734,3 +906,59 @@ class OlasServiceImporter:
734
906
  return False, "Olas plugin not available"
735
907
  except Exception as e:
736
908
  return False, str(e)
909
+
910
+ def _attempt_decryption(self, key: DiscoveredKey) -> None:
911
+ """Attempt to decrypt an encrypted keystore using the provided password."""
912
+ if not self.password or not key.encrypted_keystore:
913
+ return
914
+
915
+ try:
916
+ logger.debug(f"Attempting decryption for {key.address}")
917
+
918
+ # Use Account.decrypt to handle standard web3 keystores
919
+ private_key_bytes = Account.decrypt(key.encrypted_keystore, self.password)
920
+ key.private_key = private_key_bytes.hex()
921
+ key.is_encrypted = False
922
+ # If we successfully decrypted, it's no longer "encrypted" for verification purposes
923
+ logger.debug(f"Successfully decrypted key for {key.address}")
924
+ except ValueError as e:
925
+ # Password incorrect
926
+ logger.warning(f"Decryption failed (ValueError) for {key.address}: {e}")
927
+ pass
928
+ except Exception as e:
929
+ logger.warning(f"Error decrypting key {key.address}: {type(e).__name__} - {e}")
930
+
931
+ def _verify_key_signature(self, key: DiscoveredKey) -> None:
932
+ """Verify that the plaintext private key can sign a message and recover the address."""
933
+ if not key.private_key or not key.address:
934
+ return
935
+
936
+ try:
937
+ from eth_account.messages import encode_defunct
938
+
939
+ message = "Hello, world!"
940
+ encoded_message = encode_defunct(text=message)
941
+ signed_message = Account.sign_message(encoded_message, private_key=key.private_key)
942
+ recovered_address = Account.recover_message(
943
+ encoded_message, signature=signed_message.signature
944
+ )
945
+
946
+ # Normalize address to lowercase with 0x prefix
947
+ key_addr = key.address.lower()
948
+ if not key_addr.startswith("0x"):
949
+ key_addr = "0x" + key_addr
950
+ recovered_addr = recovered_address.lower()
951
+
952
+ if recovered_addr == key_addr:
953
+ key.signature_verified = True
954
+ logger.debug(f"Signature verified for key {key.address}")
955
+ else:
956
+ key.signature_failed = True
957
+ logger.warning(
958
+ f"Signature verification FAILED for key {key.address}. "
959
+ f"Recovered: {recovered_address}"
960
+ )
961
+ except Exception as e:
962
+ key.signature_failed = True
963
+ logger.warning(f"Error verifying signature for key {key.address}: {e}")
964
+
@@ -1,10 +1,11 @@
1
1
  """Olas plugin."""
2
2
 
3
3
  from pathlib import Path
4
- from typing import Dict, Optional, Type
4
+ from typing import Dict, List, Optional, Tuple, Type
5
5
 
6
6
  import typer
7
7
  from pydantic import BaseModel
8
+ from rich.console import Console
8
9
 
9
10
  from iwa.core.plugins import Plugin
10
11
  from iwa.core.wallet import Wallet
@@ -86,7 +87,18 @@ class OlasPlugin(Plugin):
86
87
  # Query failed - Safe likely doesn't exist
87
88
  return [], False
88
89
 
89
- def _display_service_table(self, console, service, index: int) -> None:
90
+ def _resolve_staking_name(self, address: str, chain_name: str) -> str | None:
91
+ """Resolve staking contract address to human-readable name."""
92
+ from iwa.plugins.olas.constants import OLAS_TRADER_STAKING_CONTRACTS
93
+
94
+ chain_contracts = OLAS_TRADER_STAKING_CONTRACTS.get(chain_name, {})
95
+ addr_lower = address.lower()
96
+ for name, contract_addr in chain_contracts.items():
97
+ if str(contract_addr).lower() == addr_lower:
98
+ return name
99
+ return None
100
+
101
+ def _display_service_table(self, console: Console, service, index: int) -> None:
90
102
  """Display a single discovered service as a Rich table."""
91
103
  from rich.table import Table
92
104
 
@@ -98,46 +110,102 @@ class OlasPlugin(Plugin):
98
110
 
99
111
  table.add_row("Format", service.format)
100
112
  table.add_row("Source", str(service.source_folder))
101
- table.add_row("Service ID", str(service.service_id) if service.service_id else "N/A")
113
+ table.add_row(
114
+ "Service ID",
115
+ str(service.service_id) if service.service_id else "[red]Not detected[/red]",
116
+ )
102
117
  table.add_row("Chain", service.chain_name)
103
118
 
104
119
  # Verify Safe and display
120
+ on_chain_signers, safe_exists = self._add_safe_info(table, service)
121
+
122
+ # Display staking contract info
123
+ self._add_staking_info(table, service)
124
+
125
+ # Display owner
126
+ self._add_owner_info(table, service)
127
+
128
+ # Display agent key
129
+ self._add_agent_info(table, service, on_chain_signers, safe_exists)
130
+
131
+ console.print(table)
132
+ console.print()
133
+
134
+ def _add_safe_info(self, table, service) -> Tuple[Optional[List[str]], Optional[bool]]:
135
+ """Add Safe information to the display table."""
105
136
  on_chain_signers, safe_exists = None, None
106
137
  if service.safe_address:
107
138
  on_chain_signers, safe_exists = self._get_safe_signers(
108
139
  service.safe_address, service.chain_name
109
140
  )
110
- if safe_exists is None:
111
- table.add_row("Safe", service.safe_address)
112
- elif safe_exists:
113
- table.add_row("Safe", f"{service.safe_address} [green]✓[/green]")
114
- else:
115
- table.add_row(
116
- "Safe",
117
- f"[bold red]⚠ {service.safe_address} - DOES NOT EXIST ON-CHAIN![/bold red]",
141
+ safe_text = service.safe_address
142
+ if safe_exists:
143
+ safe_text += " [green]✓[/green]"
144
+ elif safe_exists is False:
145
+ safe_text = (
146
+ f"[bold red]⚠ {service.safe_address} - DOES NOT EXIST ON-CHAIN![/bold red]"
118
147
  )
148
+ table.add_row("Multisig", safe_text)
119
149
  else:
120
- table.add_row("Safe", "N/A")
121
-
122
- # Display keys with signer verification
123
- for key in service.keys:
124
- status = "🔒 encrypted" if key.is_encrypted else "🔓 plaintext"
125
- key_info = f"{key.address} {status}"
126
-
127
- if key.role == "agent" and service.safe_address:
128
- if not safe_exists:
129
- key_info = f"[bold red]⚠ {key.address} - NOT A SIGNER OF THE SAFE![/bold red]"
150
+ table.add_row("Multisig", "[red]Not detected[/red]")
151
+ return on_chain_signers, safe_exists
152
+
153
+ def _add_staking_info(self, table, service) -> None:
154
+ """Add staking information to the display table."""
155
+ if service.staking_contract_address:
156
+ staking_name = self._resolve_staking_name(
157
+ service.staking_contract_address, service.chain_name
158
+ )
159
+ val = staking_name if staking_name else "[red]Unknown[/red]"
160
+ table.add_row("Staking", val)
161
+ table.add_row("Staking Addr", service.staking_contract_address)
162
+ else:
163
+ table.add_row("Staking", "[red]Not detected[/red]")
164
+ table.add_row("Staking Addr", "[red]Not detected[/red]")
165
+
166
+ def _add_owner_info(self, table, service) -> None:
167
+ """Add owner information to the display table."""
168
+ owner_key = next((k for k in service.keys if k.role == "owner"), None)
169
+ owner_addr = service.service_owner_address
170
+ if not owner_addr and owner_key:
171
+ owner_addr = owner_key.address
172
+
173
+ if owner_addr:
174
+ val = owner_addr
175
+ if owner_key:
176
+ if owner_key.signature_verified:
177
+ val = f"[green]{owner_addr}[/green]"
178
+ elif not owner_key.is_encrypted:
179
+ val = f"[red]{owner_addr}[/red]"
180
+ status = "🔒 encrypted" if owner_key.is_encrypted else "🔓 plaintext"
181
+ table.add_row("Owner", f"{val} {status}")
182
+ else:
183
+ table.add_row("Owner", val)
184
+ else:
185
+ table.add_row("Owner", "[red]Not detected[/red]")
186
+
187
+ def _add_agent_info(self, table, service, on_chain_signers, safe_exists) -> None:
188
+ """Add agent information to the display table."""
189
+ agent_key = next((k for k in service.keys if k.role == "agent"), None)
190
+ if agent_key:
191
+ status = "🔒 encrypted" if agent_key.is_encrypted else "🔓 plaintext"
192
+ addr_val = agent_key.address
193
+ if agent_key.signature_verified:
194
+ addr_val = f"[green]{agent_key.address}[/green]"
195
+ elif not agent_key.is_encrypted:
196
+ addr_val = f"[red]{agent_key.address}[/red]"
197
+
198
+ key_info = f"{addr_val} {status}"
199
+ if service.safe_address:
200
+ if safe_exists is False:
201
+ key_info = f"[bold red]⚠ {agent_key.address} - NOT A SIGNER![/bold red]"
130
202
  elif on_chain_signers is not None:
131
- is_signer = key.address.lower() in [s.lower() for s in on_chain_signers]
203
+ is_signer = agent_key.address.lower() in [s.lower() for s in on_chain_signers]
132
204
  if not is_signer:
133
- key_info = (
134
- f"[bold red]⚠ {key.address} - NOT A SIGNER OF THE SAFE![/bold red]"
135
- )
136
-
137
- table.add_row(f"Key ({key.role})", key_info)
138
-
139
- console.print(table)
140
- console.print()
205
+ key_info = f"[bold red]⚠ {agent_key.address} - NOT A SIGNER![/bold red]"
206
+ table.add_row("Agent", key_info)
207
+ else:
208
+ table.add_row("Agent", "[red]Not detected[/red]")
141
209
 
142
210
  def _import_and_print_results(self, console, importer, discovered, password) -> tuple:
143
211
  """Import all discovered services and print results."""
@@ -194,15 +262,18 @@ class OlasPlugin(Plugin):
194
262
  ),
195
263
  ):
196
264
  """Import Olas services and keys from external directories."""
197
- from rich.console import Console
198
-
199
265
  from iwa.plugins.olas.importer import OlasServiceImporter
200
266
 
201
267
  console = Console()
202
268
 
203
269
  # Scan directory
204
270
  console.print(f"\n[bold]Scanning[/bold] {path}...")
205
- importer = OlasServiceImporter()
271
+
272
+ # Ask for password in dry-run to allow signature verification of encrypted keys
273
+ if dry_run and not password:
274
+ password = self._prompt_dry_run_password()
275
+
276
+ importer = OlasServiceImporter(password=password)
206
277
  discovered = importer.scan_directory(Path(path))
207
278
 
208
279
  if not discovered:
@@ -219,11 +290,9 @@ class OlasPlugin(Plugin):
219
290
  raise typer.Exit(code=0)
220
291
 
221
292
  # Confirm import
222
- if not yes:
223
- confirm = typer.confirm("Import these services?")
224
- if not confirm:
225
- console.print("[yellow]Aborted.[/yellow]")
226
- raise typer.Exit(code=0)
293
+ if not yes and not typer.confirm("Import these services?"):
294
+ console.print("[yellow]Aborted.[/yellow]")
295
+ raise typer.Exit(code=0)
227
296
 
228
297
  # Check if we need a password for encrypted keys
229
298
  needs_password = any(key.is_encrypted for service in discovered for key in service.keys)
@@ -234,11 +303,28 @@ class OlasPlugin(Plugin):
234
303
  password = typer.prompt("Password", hide_input=True)
235
304
 
236
305
  # Import services
237
- total_keys, total_safes, total_services, all_skipped, all_errors = (
238
- self._import_and_print_results(console, importer, discovered, password)
306
+ results = self._import_and_print_results(console, importer, discovered, password)
307
+ self._print_import_summary(console, *results)
308
+
309
+ def _prompt_dry_run_password(self) -> Optional[str]:
310
+ """Prompt for password during dry-run."""
311
+ pwd = typer.prompt(
312
+ "Enter wallet password to verify encrypted keys (optional, press Enter to skip)",
313
+ hide_input=True,
314
+ default="",
239
315
  )
316
+ return pwd if pwd else None
240
317
 
241
- # Summary
318
+ def _print_import_summary(
319
+ self,
320
+ console: Console,
321
+ total_keys: int,
322
+ total_safes: int,
323
+ total_services: int,
324
+ all_skipped: List[str],
325
+ all_errors: List[str],
326
+ ) -> None:
327
+ """Print import summary."""
242
328
  console.print("\n[bold]Summary:[/bold]")
243
329
  console.print(f" Keys imported: {total_keys}")
244
330
  console.print(f" Safes imported: {total_safes}")
@@ -161,7 +161,7 @@ def test_parse_trader_runner_keys(importer, tmp_path):
161
161
  assert service.safe_address == "0xSafeAddress"
162
162
  assert len(service.keys) == 2
163
163
  assert any(k.role == "agent" for k in service.keys)
164
- assert any(k.role == "operator" for k in service.keys)
164
+ assert any(k.role == "owner" for k in service.keys)
165
165
 
166
166
 
167
167
  def test_parse_trader_runner_invalid_id(importer, tmp_path):
@@ -68,7 +68,7 @@ def test_import_services_cli_scan_only(plugin, runner):
68
68
  ]
69
69
 
70
70
  # Test dry-run
71
- result = runner.invoke(app, ["/tmp/test", "--dry-run"])
71
+ result = runner.invoke(app, ["/tmp/test", "--dry-run"], input="\n")
72
72
  assert result.exit_code == 0
73
73
  assert "Found 1 service(s)" in result.output
74
74
  assert "Dry run mode" in result.output
@@ -185,15 +185,15 @@ def test_import_services_cli_complex_display(plugin, runner):
185
185
  # Mock Safe exists with Agent as signer
186
186
  mock_get_signers.return_value = (["0xAgent"], True)
187
187
 
188
- result = runner.invoke(app, ["/tmp/test", "--dry-run"])
188
+ result = runner.invoke(app, ["/tmp/test", "--dry-run"], input="\n")
189
189
  assert "0xSafe" in result.output
190
190
  assert "✓" in result.output
191
191
  assert "0xAgent 🔓 plaintext" in result.output # Not a warning
192
192
 
193
193
  # 2. Service where agent is NOT a signer
194
194
  mock_get_signers.return_value = (["0xOther"], True)
195
- result = runner.invoke(app, ["/tmp/test", "--dry-run"])
196
- assert "NOT A SIGNER OF THE SAFE" in result.output
195
+ result = runner.invoke(app, ["/tmp/test", "--dry-run"], input="\n")
196
+ assert "NOT A SIGNER!" in result.output
197
197
 
198
198
 
199
199
  def test_import_services_cli_password_prompt(plugin, runner):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.21
3
+ Version: 0.0.23
4
4
  Summary: A secure, modular, and plugin-based framework for crypto agents and ops
5
5
  Requires-Python: <4.0,>=3.12
6
6
  Description-Content-Type: text/markdown
@@ -61,10 +61,10 @@ iwa/plugins/gnosis/tests/test_safe.py,sha256=hQHVHBWQhGnuvzvx4U9fOWEwASJWwql42q6
61
61
  iwa/plugins/olas/__init__.py,sha256=_NhBczzM61fhGYwGhnWfEeL8Jywyy_730GASe2BxzeQ,106
62
62
  iwa/plugins/olas/constants.py,sha256=iTFoO2QW3KbhL5k5sKsJxxyDytl9wVIb_9hAih55KrE,7728
63
63
  iwa/plugins/olas/events.py,sha256=SWD3wYdQ-l6dLUJSkfh_WsLmedH4Vsw_EvYXg7QC3yc,5970
64
- iwa/plugins/olas/importer.py,sha256=f8KlZ9dGcNbpg8uoTYbO9sDvbluZoslhpWFLqPjPnLA,26717
64
+ iwa/plugins/olas/importer.py,sha256=gf6BQKcaaPiFnjiBW3tv4ZVp2HoHw4xY1NS6r4Ip-8o,37447
65
65
  iwa/plugins/olas/mech_reference.py,sha256=CaSCpQnQL4F7wOG6Ox6Zdoy-uNEQ78YBwVLILQZKL8Q,5782
66
66
  iwa/plugins/olas/models.py,sha256=xC5hYakX53pBT6zZteM9cyiC7t6XRLLpobjQmDYueOo,3520
67
- iwa/plugins/olas/plugin.py,sha256=h7Wjw-ozalu9ocZbFcQGEw_TM8fgW6Qyh_DY1VOGeLY,9393
67
+ iwa/plugins/olas/plugin.py,sha256=sv1Hx4-wHZOwpdjhrpvxrFaA44wiGkcCXjbI_7NLH2Y,13127
68
68
  iwa/plugins/olas/contracts/activity_checker.py,sha256=WXxuzbpXGVqIfEiMPiiqN3Z_UxIY-Lvx0raa1ErBfPA,5323
69
69
  iwa/plugins/olas/contracts/base.py,sha256=y73aQbDq6l4zUpz_eQAg4MsLkTAEqjjupXlcvxjfgCI,240
70
70
  iwa/plugins/olas/contracts/mech.py,sha256=dXYtyORc-oiu9ga5PtTquOFkoakb6BLGKvlUsteygIg,2767
@@ -92,7 +92,7 @@ iwa/plugins/olas/service_manager/mech.py,sha256=NVzVbEmyOe3wK92VEzCCOSuy3HDkEP1M
92
92
  iwa/plugins/olas/service_manager/staking.py,sha256=7REp_HziKtqF9uSvbcq01C9XiaxgVT3gCimuLAAdNnM,28219
93
93
  iwa/plugins/olas/tests/conftest.py,sha256=4vM7EI00SrTGyeP0hNzsGSQHEj2-iznVgzlNh2_OGfo,739
94
94
  iwa/plugins/olas/tests/test_importer.py,sha256=i9LKov7kNRECB3hmRnhKBwcfx3uxtjWe4BB77bOOpeo,4282
95
- iwa/plugins/olas/tests/test_importer_error_handling.py,sha256=X37TrvJ6-3-NuJ2megm0Cnx3KJdA1wke563pRf_EPJk,12084
95
+ iwa/plugins/olas/tests/test_importer_error_handling.py,sha256=O5yd7w_eURtkJb8_IwAGkz8fyHLTzYfI5c2JxWl3oOo,12081
96
96
  iwa/plugins/olas/tests/test_mech_contracts.py,sha256=wvxuigPafF-ySIHVBdWVei3AO418iPh7cSVdAlUGm_s,3566
97
97
  iwa/plugins/olas/tests/test_olas_contracts.py,sha256=B8X-5l1KfYMoZOiM94_rcNzbILLl78rqt_jhyxzAOqE,10835
98
98
  iwa/plugins/olas/tests/test_olas_integration.py,sha256=vjL8-RNdxXu6RFR5F1Bn7xqnxnUVWTzl2--Pp7-0r5A,22973
@@ -101,7 +101,7 @@ iwa/plugins/olas/tests/test_olas_view.py,sha256=kh3crsriyoRiZC6l8vzGllocvQnYmqzi
101
101
  iwa/plugins/olas/tests/test_olas_view_actions.py,sha256=jAxr9bjFNAaxGf1btIrxdMaHgJ0PWX9aDwVU-oPGMpk,5109
102
102
  iwa/plugins/olas/tests/test_olas_view_modals.py,sha256=8j0PNFjKqFC5V1kBdVFWNLMvqGt49H6fLSYGxn02c8o,5562
103
103
  iwa/plugins/olas/tests/test_plugin.py,sha256=RVgU-Cq6t_3mOh90xFAGwlJOV7ZIgp0VNaK5ZAxisAQ,2565
104
- iwa/plugins/olas/tests/test_plugin_full.py,sha256=HdEJf_beqH18TaA-PFFMsKFA4ycXY-bz_kbaHQknaic,8390
104
+ iwa/plugins/olas/tests/test_plugin_full.py,sha256=GBZ-TL5t8NXy6HVQozCI1hMEn2EY4lXjGFC89QpoPxQ,8415
105
105
  iwa/plugins/olas/tests/test_service_lifecycle.py,sha256=sOCtpz8T9s55AZe9AoqP1h3XrXw5NDSjDqwLgYThvU4,5559
106
106
  iwa/plugins/olas/tests/test_service_manager.py,sha256=rS2m0A26apc-o4HsfP5oXmVcmZSR5e874bjhQKZRaSg,40650
107
107
  iwa/plugins/olas/tests/test_service_manager_errors.py,sha256=udlAsQj_t1F5TwVQuWhroF6jDJ4RmGEXaxPh87tMsuA,8538
@@ -157,7 +157,7 @@ iwa/web/tests/test_web_endpoints.py,sha256=C264MH-CTyDW4GLUrTXBgLJKUk4-89pFAScBd
157
157
  iwa/web/tests/test_web_olas.py,sha256=0CVSsrncOeJ3x0ECV7mVLQV_CXZRrOqGiVjgLIi6hZ8,16308
158
158
  iwa/web/tests/test_web_swap.py,sha256=7A4gBJFL01kIXPtW1E1J17SCsVc_0DmUn-R8kKrnnVA,2974
159
159
  iwa/web/tests/test_web_swap_coverage.py,sha256=zGNrzlhZ_vWDCvWmLcoUwFgqxnrp_ACbo49AtWBS_Kw,5584
160
- iwa-0.0.21.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
160
+ iwa-0.0.23.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
161
161
  tests/legacy_cow.py,sha256=oOkZvIxL70ReEoD9oHQbOD5GpjIr6AGNHcOCgfPlerU,8389
162
162
  tests/legacy_safe.py,sha256=AssM2g13E74dNGODu_H0Q0y412lgqsrYnEzI97nm_Ts,2972
163
163
  tests/legacy_transaction_retry_logic.py,sha256=D9RqZ7DBu61Xr2djBAodU2p9UE939LL-DnQXswX5iQk,1497
@@ -210,8 +210,8 @@ tests/test_utils.py,sha256=vkP49rYNI8BRzLpWR3WnKdDr8upeZjZcs7Rx0pjbQMo,1292
210
210
  tests/test_workers.py,sha256=MInwdkFY5LdmFB3o1odIaSD7AQZb3263hNafO1De5PE,2793
211
211
  tools/create_and_stake_service.py,sha256=1xwy_bJQI1j9yIQ968Oc9Db_F6mk1659LuuZntTASDE,3742
212
212
  tools/verify_drain.py,sha256=PkMjblyOOAuQge88FwfEzRtCYeEtJxXhPBmtQYCoQ-8,6743
213
- iwa-0.0.21.dist-info/METADATA,sha256=G_17iq72W9SNwyz3rr7rrvVgRA_iKLlVgxUnbrXdLNk,7295
214
- iwa-0.0.21.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
215
- iwa-0.0.21.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
216
- iwa-0.0.21.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
217
- iwa-0.0.21.dist-info/RECORD,,
213
+ iwa-0.0.23.dist-info/METADATA,sha256=M0c-f0k7QPegtSsPkXm8gP8Ed12IS_8zEJZMNGMK3Ug,7295
214
+ iwa-0.0.23.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
215
+ iwa-0.0.23.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
216
+ iwa-0.0.23.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
217
+ iwa-0.0.23.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5